lib-web-prerender
@vfuk/lib-web-prerender is a small, shared Express middleware published as an npm package and
installed by Vodafone UK web shops. Its one job is to make pages appear instantly and be
readable by Google, by keeping a pre-built copy of each page's finished HTML in a shared Redis
cache and serving that copy instead of making every visitor's browser rebuild the page from scratch.
Speed
Returning a ready-made HTML page from Redis is far faster than asking the browser to download JavaScript, call APIs, and then draw the page.
SEO
Search engines get real HTML with real content and prices in it, not an empty shell that only fills in after JavaScript runs.
Shared, Not Copy-Pasted
Every Vodafone shop uses the same battle-tested caching logic. Fix a bug once, publish a version, all consumers benefit.
Never Breaks The Page
Every failure path falls back to next() — the normal client-side render. A broken cache makes
the site slower, never blank.
It is a cache in front of your web app that stores finished HTML pages, decides who is safe to serve a cached page to, and quietly refreshes pages in the background when they go stale.
At a glance
| Fact | Value |
|---|---|
| Package name | @vfuk/lib-web-prerender |
| Version at time of writing | 0.35.0 |
| Type | Express middleware library (not a running service) |
| Language | JavaScript (ES modules, compiled by Babel) |
| Runtime | Node 18.20.5 (Volta pinned); CI builds on Node 22.14.0 |
| Source files that matter | ~28 small single-purpose modules under src/ |
| Hard requirements | A Redis client, and a URL for the external prerender service |
| Owner / contact | Ben Welsh (per README.md) |
| Licence | Unlicense (internal) |
Plain-English Description
No code knowledge assumed. Read this section on its own and you will understand the repo.
The problem, using a restaurant
Imagine a restaurant where every single dish is cooked completely from scratch the moment a customer sits down. Chopping, simmering, plating — all of it, per customer. The food is fine, but everyone waits a long time, and the kitchen does the same work over and over for people who all ordered the same thing.
A modern Vodafone web page works like that. When you open a page, your phone or laptop downloads a big bundle of JavaScript, that JavaScript then phones several Vodafone systems to ask "what plans exist?", "what do they cost?", "what does the marketing team want shown today?", waits for all those answers, and only then draws the page. Meanwhile you are looking at a blank or half-finished screen.
What this repo does about it
This repo is the restaurant's hot counter. The first time somebody orders a dish, the kitchen cooks it properly and puts an extra portion on the hot counter. Everybody who orders that same dish for the next few minutes is served from the counter immediately. Every so often the portion on the counter is thrown out and replaced with a freshly cooked one, so nothing goes stale.
Six things it is careful about
1. Never serve the wrong person's page
A cached page is shared by everyone, so it must contain nothing personal. If the library can tell you are logged in or already shopping (you have a basket, a journey, a signed-in session), it refuses to use the cache and lets the site build your page fresh and personal.
2. Different pages are different dishes
A page for business customers is not the same page as one for consumers, even at the same web address. The library builds a "label" for each cached copy out of the address plus the specific bits of the web address and cookies the consuming shop declared as meaningful.
3. Freshness has a timer
Each cached page records when it was made. Past its expiry (10 minutes by default) the library still hands out the slightly-stale copy — nobody waits — while simultaneously ordering a fresh one in the background.
4. A new release throws away the old menu
When developers deploy a new version of a shop, cached pages from the old version point at files that no longer exist. Every cached page is stamped with a release ID, and any page with the wrong stamp is rejected and regenerated.
5. Google gets a special plate
Google's crawler gets an extra-safe version of the page with all the interactive scripts stripped out (structured data is kept). It is stored separately and lasts three days.
6. If anything goes wrong, the site still works
Cache unreadable? Prerender service down? Page half-built? In every one of those cases the library steps aside and lets the site render normally. Slower, but never broken.
It is not a website, not a server you can visit, and not the thing that actually renders pages. The actual rendering is done by a separate prerender service (a headless browser) that this library talks to over HTTP. This repo is only the decision-making and caching layer that sits inside each shop.
Technical Description
For engineers. Assumes Express, Redis, and SPA/SSR concepts.
@vfuk/lib-web-prerender exports a single factory function as its default export. You call it once
with an options object at server start-up, and it returns a standard Express middleware
(req, res, next). It must be mounted before the middleware that performs
client-side rendering, because its entire contract is "either I respond, or I call next()".
import prerender from '@vfuk/lib-web-prerender'
app.use(prerender({ redis, prerenderServerUrl, /* ...options */ }))
app.get('/*', clientSideRender) // fallback renderer
Architecture
consuming web shop
this library
shared page cache
POST /render, headless browser
next() fallback
Design characteristics
-
Stale-while-revalidate. Expired entries are still served; the refresh is fired without
awaitso the response is never blocked by a prerender round trip. -
Single-flight via a cache flag. Before a prerender is requested, an
isCurrentlyPrerendering: truemarker is written to the cache key. Concurrent requests see the marker and fall through to CSR rather than stampeding the prerender service. -
Two caches per URL. A user cache (
<env>/path/?query, 24h TTL in Redis, logical freshness governed bycacheSeconds) and a bot cache (static-<env>/path/, 72h TTL, scripts stripped). -
Build-hash invalidation. Cached HTML must contain the current
buildHash; mismatches are treated as stale-from-a-previous-release and regenerated. -
Data hydration contract. The prerender service is asked to execute
window.prerenderData = window._hydratedData. The library then inlines that captured API data back into the HTML so the client boots without repeating the same network calls. -
Correctness gate on the render. A render is only cached if the HTTP status is 2xx (or 404),
the HTML contains the current build hash, and
prerenderData.outstandingRequiredRequests === 0— i.e. no request marked "required" was still in flight when the snapshot was taken. -
Fail-open everywhere. Every catch block clears the in-progress flag and calls
next().
Published shape
yarn build compiles src/ with Babel twice: a modern build into lib/
and a legacy build into lib/legacy/. Tests and mocks are excluded. The middleware subtree is
compiled with a Node 18.20 target; everything else targets browsers. package.json is copied into
lib/, and lib/ is what gets npm published.
Why This Repo Exists
1. Client-side rendering is slow on first paint
Vodafone shops are JavaScript applications. Without help, a first-time visitor pays for bundle download, parse, execute, and a fan-out of API calls before seeing content. Prerendering removes all of that from the critical path for cache hits.
2. Commercial pages have to be indexable
Plans, pricing and marketing pages are acquisition surfaces. Crawlers must see prices and copy in the raw HTML. The dedicated Googlebot cache exists purely for this.
3. The logic is subtle and must not be duplicated
Deciding who may receive a shared page, how to key it, when it is stale, how to avoid stampedes, and how to invalidate on release is genuinely tricky. Re-implementing that in every shop would guarantee divergence and privacy bugs. One package, one implementation.
4. Each shop still needs to differ
Different shops care about different query parameters, cookies, auth cookie prefixes, cache durations and skip rules — hence the options object rather than hard-coded behaviour.
5. Personalisation must be respected
Logged-in and mid-journey users must never receive another user's HTML. The skip rules in
initShouldSkipPrerender are the privacy boundary of the whole system.
Serve fast, cacheable, crawler-friendly HTML to anonymous visitors, and get out of the way entirely for everyone else.
Who Consumes It
The library is imported by Vodafone UK web shop applications. The clearest signal in the repo itself is
.linkrc.js, which whitelists local linking only into projects prefixed
web-shop-, and the README.md, which documents the integration using
web-shop-simo as the worked example.
What a consumer must provide
- A Redis client (usually an
iorediscluster) - A
prerenderServerUrlpointing at the prerender service - A client-side render middleware mounted after this one
- HTML containing the placeholder markers this library replaces
- Optionally:
hydrate()calls around its API requests
What a consumer gets back
- Cache hits answered directly with
X-Prerender-Cache: true - Background cache warming and refreshing
- A script-free static variant for Googlebot
- Captured API data inlined as
window._hydratedData - Automatic cache invalidation on every release
Consumers pin a version in their package.json. Changes here are released by merging to
main with a semantic-version keyword in the PR title, which publishes a new package version.
Nothing changes for a consumer until they bump.
Quick Recap: The Problem It Solves
This whole group of pages is one thing: a real, worked-through explanation of prerender using
web-shop-simo (the Vodafone SIM-only shop) as the living example — how it's switched on, how to
see it working in a browser, what overrides it, what happens when a price changes, what Redis actually is, and
how to test the new organisationId setting from PR #123.
Without prerender, a visitor's browser downloads code and calls APIs before drawing the page — slow, and invisible to Google. With prerender, a finished copy of the page is built once and handed out instantly to the next visitor, while staying completely invisible to anyone who is logged in or mid-purchase.
The 4 Pieces & Where They Run
Knowing which of these four things is "code you're looking at" vs. "a real server somewhere" clears up most of the confusion.
| Piece | What it actually is | Runs where |
|---|---|---|
| lib-web-prerender | An npm package. Just code — no server, no port of its own. | Baked directly into web-shop-simo's own server process when it starts up. |
| web-shop-simo | The actual Vodafone SIM-only shop (Express + React). | Its own deployment, per environment. |
| Prerender Service | A real, separate running server — a headless browser that builds the finished HTML. | Its own deployment, reached over HTTP via PRERENDER_SERVICE_URL. |
| Redis | A real, separate running database — the shared cache storage. | Its own deployment, reached via PRERENDER_REDIS_URL / PRERENDER_REDIS_PORT. |
When you deploy web-shop-simo, this library isn't "a server you visit" — it's just logic
running inside the shop's own Node process on every request. It calls out to the other two real servers
(Prerender Service, Redis) when it needs to.
How web-shop-simo Turns It On
File: src/server/common/middleware/prerenderMiddleware/prerender.middleware.ts — the master switch lives right at the top of it.
// The master on/off switch — no PRERENDER_SERVICE_URL, no prerender at all
if (!process.env.PRERENDER_SERVICE_URL) {
logger.group('prerender').yarn.info('Prerender disabled', 'no service url')
return // prerender middleware is never mounted — app runs with zero caching
}
app.use(
prerender({
redis: mockPrerenderRedis === 'true' ? new MockRedis() : connectRedis(),
queryParamsToRetain: getQueryParamsToRetain(),
cookiesToRetain: ['features', 'customerSegment'],
cacheSeconds: 300, // 5 minutes before a background refresh
skipChecker: (req) => req.cookies.basketId, // extra belt-and-suspenders skip
prerenderServerUrl,
authCookiePrefix,
buildHash: process.env.BUILD_BUILDID ?? 'fallback_hash',
environment: process.env.ENVIRONMENT,
...
}),
)
What each line means, plainly
redis
Where cached pages are stored — a real Redis, or a fake in-memory one for local dev.
queryParamsToRetain
E.g. segment matters, utm_source doesn't. Only listed params split the cache into separate entries.
cacheSeconds: 300
A page is "fresh" for 5 minutes, then it's served once more while quietly being rebuilt in the background.
skipChecker
If you already have a basket, you're mid-purchase — never show you someone else's cached page.
buildHash
Tags every cached page with the current app version (the CI build ID), so a new deployment automatically throws out old cached pages.
environment
Keeps different environments' caches from mixing if they ever shared one Redis.
As of today, web-shop-simo does not pass organisationId — that's
the new setting from PR #123. It exists in the library, but this shop hasn't opted into it. See
Testing The New orgId Flow.
Environments & The On/Off Switch
The single on/off switch is: is PRERENDER_SERVICE_URL set? Where that value
comes from is different for local development vs. real deployed environments.
| Environment | Where the value comes from | Value |
|---|---|---|
Local (checked-in .env files) |
.env/common.env, .env/local-prod-prerender.env |
Hard-coded http://localhost:3000 — for local dev only |
| Real environments (int1, qc1, qc2, prod) |
Not from the .env files — those are local-dev only. Real environments
get it from infrastructure/cf-fetemplate.yaml (CloudFormation)
|
!Ref WebAlbDnsNameSsm — the internal load-balancer address of the real prerender-service deployment, pulled from AWS SSM at deploy time |
Same story for Redis in real environments — PRERENDER_REDIS_URL / PRERENDER_REDIS_PORT come from !Ref WebRedisEndpointSsm (a real AWS-managed Redis endpoint), not 127.0.0.1.
prerenderEnabled in the CloudFormation template sets PRERENDER_BOILERPLATE — this
is a "which HTML marker convention to use" switch, not the master on/off switch. Don't confuse the two.
Running it locally
yarn start:prerender
| Setting | Local value | Meaning |
|---|---|---|
PRERENDER_SERVICE_URL | http://localhost:3000 | Where the headless-browser render service is expected |
PRERENDER_APP_ENDPOINT | http://localhost:8000 | The shop's own address — the prerender service calls this back |
PRERENDER_MOCK_REDIS | true | Skips real Redis, uses an in-memory fake — quickest way to test locally |
PRERENDER_BOILERPLATE | true | Uses the newer HTML placeholder convention |
How To Check It's Working
You don't need server access — check response headers.
curl -sI https://<host>/en/sim-only | grep -i "x-prerender-cache"
Header present — X-Prerender-Cache: true
You were served a cached page. Prerender is active and this request was a hit.
Header absent
Either prerender is disabled entirely, or it's enabled but this request was a miss/skip — cookies are the usual reason (see next section).
Same check in Chrome DevTools → Network tab → click the document request → Response Headers → look for x-prerender-cache.
Also useful: view-source on the page. If you see var _isPrerender = false; and a populated
var _hydratedData = {...} (not empty {}), that's a cached/prerendered page. Empty
_hydratedData means it was rendered fresh in your browser.
"I Changed The Price…" Scenario
Two different kinds of "stale", handled by two different mechanisms.
Case A — price/content changed, no deploy happenedCommon
- The cached page has a freshness window —
cacheSeconds: 300(5 minutes) in web-shop-simo - For those 5 minutes, a visitor can see the old price in a cached page
- Once 5 minutes pass, the next visitor still gets served the (now stale) page once more — but that request also silently triggers a background rebuild
- The visitor after that gets the new price
Worst case: stale pricing for roughly cacheSeconds + one more visit's worth of time. This is a deliberate speed-vs-freshness trade-off — lower cacheSeconds if pricing needs to be fresher, at the cost of more traffic to the prerender service.
Case B — a new deployment happenedSelf-fixing
- Every cached page is stamped with a
buildHash(in web-shop-simo: the CI build ID) - After a deploy, the new server instance has a new
buildHash - Old cached pages in Redis still carry the old
buildHash - On the next request, the library sees the mismatch and refuses to serve the old page — a fresh render happens while it rebuilds
This is what stops old cached pages referencing deleted JS/CSS files after a deploy — no manual cache flush needed.
Content-only price changes → a few minutes of possible staleness, governed by cacheSeconds.
Code deployments → self-invalidate immediately via buildHash.
Redis, Explained Simply
For anyone with zero Redis background.
Think of Redis as a shared whiteboard that every server instance can read and write to instantly.
- It's not a database with tables and SQL — it's a giant key→value store, like a dictionary: "give me the value stored under this label"
- It lives in memory (RAM), which is why it's extremely fast — much faster than rebuilding a page from scratch
- If web-shop-simo runs several server instances behind a load balancer, they all point at the same Redis — a page cached by instance #1 can be served by instance #2
What this library actually stores, per page
Key: /plans/?segment=business (the "label")
Value: {
htmlContent: "<!DOCTYPE html>...", the finished page
statusCode: 200,
timeOfCache: 1730000000000, when it was made
buildHash: "abc123", which app version made it
isCurrentlyPrerendering: false "is someone already rebuilding this?"
}
Only 3 Redis operations are ever used
| Operation | Plain meaning |
|---|---|
exists(key) | Is there anything stored under this label? |
get(key) | Give me what's stored under this label. |
set(key, value, 'ex', seconds) | Store this, and auto-delete it after N seconds (Redis's built-in expiry — no cleanup code needed). |
The library catches the error, logs a warning, and falls through to a normal render. The site never breaks because Redis is unavailable — it just stops being fast.
Locally, PRERENDER_MOCK_REDIS=true swaps in MockRedis — the same 3 operations,
backed by a plain JavaScript object in memory, so you can test without any real infrastructure.
Testing The New orgId Flow (PR #123)
PR #123 adds an optional organisationId setting so one shared cache can safely serve multiple
organisations without their pages colliding. web-shop-simo doesn't use it yet, so testing it is two parts:
prove the library behaves correctly on its own, then simulate it plugged into a real shop.
Part A — prove it in the library itself (fastest, no shop needed)
- Check out the PR branch in
lib-web-prerender - Run the existing unit tests:
yarn test src/middleware/helpers/getCacheableUrl yarn test src/middleware/helpers/getCacheableUrls - Confirm they pass, and note what they prove:
- Setting
organisationId: 'org-a'appends?orgId=org-ato the cache key - Special characters get URL-encoded (
org id/a&b=c→org%20id%2Fa%26b%3Dc) - Request headers are ignored — only the server-side config value is used, which proves a visitor can't spoof it
- Setting
Part B — simulate it plugged into web-shop-simo
- Link your PR branch's build into
web-shop-simolocally (via.linkrc.js's tool, oryarn link/yalc) - Temporarily add
organisationId: 'test-org-a'intoprerender.middleware.ts's config object - Run
yarn start:prerenderwithPRERENDER_MOCK_REDIS=true - Hit a page in the browser, e.g.
http://localhost:8000/plans - Check the response header:
X-Prerender-Cache: trueshould appear on a cache hit -
The real proof: change
organisationIdto'test-org-b', restart, hit the same URL again — confirm it's a fresh cache miss, not reusing org-a's cached copy. Two separate keys should exist:/plans/?orgId=test-org-a /plans/?orgId=test-org-b - Switch back to org-a — confirm it's still cached separately and unaffected by org-b's traffic
- Two different
organisationIdvalues never share a cached page for the same URL - No
organisationIdset (default'') → behaves exactly as before, noorgIdin the key - Nothing in the incoming request can change which organisation's cache is read or written — only server config can
The Big Picture
There are four moving parts. Only one of them is this repo. Understanding which is which removes most of the confusion people have when they first read the code.
| Part | What it is | Lives where | Role |
|---|---|---|---|
| The web shop | An Express + JavaScript app | e.g. web-shop-simo |
Owns the routes and the client-side renderer. Installs this library. |
| This library | Express middleware | lib-web-prerender |
Decides: serve from cache, or step aside. Manages the cache. |
| Redis | Shared in-memory store | Infrastructure | Holds the rendered HTML plus metadata, keyed per page variant. |
| Prerender service | Headless-browser HTTP service | Separate deployment | Takes a URL, loads it fully, returns finished HTML + captured API data. |
The prerender service renders the shop by requesting the shop's own URL. So the shop calls the
prerender service, which calls the shop back. The library sets an x-prerender header path on
that inner request so the middleware recognises it and skips itself — otherwise it would loop forever.
Request Lifecycle
This is the exact order of operations in src/middleware/prerender.js, the file that orchestrates
everything else.
Once, at server start-up
defaultOptions with your optionsprerenderServerUrladd https:// if missing
MockRedislocal/development only
Then, on every request
1Should we skip entirely?Gate
shouldSkipPrerender(req, res) runs first. If it returns truthy the middleware immediately
calls next() and does nothing else — no Redis access, no prerender request. See
When Prerender Is Skipped for the full rule set.
2Resolve the build hash and URL hashKeying
If a getBuildHash(req) function was supplied it is awaited and overwrites
options.buildHash for this request. Then getUrlHash(req, res) is awaited —
consumers use this to add their own dimension (e.g. an experiment bucket or brand) into the cache key.
It defaults to an empty string.
options.buildHash is mutated on the shared options object, not on a per-request copy. It
is only a problem if getBuildHash returns different values for concurrent requests.
3Compute both cache keysKeying
getCacheableUrls() returns { cacheableUrl, cacheableBotUrl } — the user key
(includes the URL hash) and the bot key (prefixed static-, no URL hash). See
Cache Keys Explained.
4Resolve a geolocation overrideOptional
If geoLocationConfig is an array, getGeoLocationOverride() finds the first
entry whose url appears in req.url and returns it with a default
accuracy of 10. This is forwarded to the prerender service so a location-specific page can
be rendered (used for store/coverage style pages).
5Assemble prerenderOptionsPlumbing
One object carrying everything downstream helpers need: all resolved options, both cache keys,
req.cookies, the optional geolocation, and — importantly — req,
res and next themselves. Every helper takes this single object.
6Branch three waysDecision
-
Googlebot and a bot cache exists →
returnStaticCachedPage(). Sends the script-stripped HTML. -
A user cache entry exists →
returnCachedPage(). Validates freshness and build, may refresh in the background, then sends or defers. -
Nothing cached → log, write the
isCurrentlyPrerenderingmarker, fireforwardToPrerenderServer()without awaiting, and callnext()so this visitor gets a normal client-side render.
static HTML, no scripts
serve, maybe refresh
CSR now, warm cache for later
The visitor who causes a cache miss never waits for the prerender. They get a normal client-side render. The prerender happens in the background and benefits the next visitor. Cache warming is always a side effect, never a blocking step.
When Prerender Is Skipped
All of this lives in src/middleware/helpers/initShouldSkipPrerender/. This is the most
safety-critical logic in the repo.
Hard skips — checked first, cheapest
| Condition | Why |
|---|---|
No prerenderServerUrl |
Nothing to render with. The library disables itself rather than erroring. |
| Not a GET request unless res.locals.isLambda |
You cannot cache the HTML result of a POST. The Lambda escape hatch exists because in that environment the method is not reliably a plain GET. |
| URL has a file extension via path.extname |
.js, .css, .png etc. are static assets, not pages. |
URL matches __vite_ping or @vite/client |
Local dev-server plumbing from Vite. Must never be prerendered. |
Then: the forced-route override
If the request has no x-prerender header and its exact req.url is
listed in alwaysPrerenderRoutes, the function returns false immediately — prerender
this, regardless of cookies. This is how a shop guarantees its highest-value SEO landing pages are always
cached.
The check is alwaysPrerenderRoutes.includes(req.url) — a full string comparison including the
query string. /plans in the list will not match a request for /plans?segment=business.
Then: the personalisation skips
x-prerender header present
This request is the prerender service rendering the app. Skipping is what breaks the infinite
loop. The consuming app also exposes this to the page as _isPrerender so it can, for example,
suppress analytics during a server render.
The visitor has a journey
True if any of req.cookies.basketId, req.cookies.JourneyID,
req.cookies.customerTransferTxnId is set, or the IDM session's
assuranceLevel >= 3. Any of these means the page would be personal.
The consumer's own skipChecker
An optional async function given (req, res). Return truthy to skip. Used for per-shop rules,
feature flags, or a manual bypass cookie during testing.
How the session is read
getSessionCookie looks up req.cookies['<prefix>_p_id_token'], falling back to
req.cookies.Session, falling back to {}. The prefix is
options.authCookiePrefix, then process.env.AUTH_COOKIE_PREFIX, then the literal
'eShop-auth'.
The middleware reads req.cookies directly. If cookie-parser is not mounted before
it, req.cookies is undefined and the journey check throws. Mount
cookieParser() first.
The three personalisation reasons are collected into an array and combined with .some(Boolean) —
any one of them is enough to skip.
Cache Keys Explained
Files involved: getCacheableUrls → getCacheableUrl → getParamString.
A cache key is the identity of a page variant. Get it too broad and two different audiences share one page. Get it too narrow and the cache never gets a hit. The library therefore builds the key from explicitly declared dimensions only.
The recipe
<static- if bot><environment><path with trailing slash><?query fragments>
Query fragments, in this fixed order:
1. hash=<urlHash> (only if getUrlHash returned something)
2. each key from queryParamsToRetain that is present in the real query string
3. each key from cookiesToRetain that is present in req.cookies
Joined with &. A present-but-empty value contributes just the key name.
Worked examples
| Incoming request | Config | Resulting key |
|---|---|---|
/plans |
no retained params, environment: '' |
/plans/ |
/plans?segment=business&utm_source=email |
queryParamsToRetain: ['segment'] |
/plans/?segment=business |
/plans?segment=business |
as above + environment: 'dev' |
dev/plans/?segment=business |
/plans with cookie brand=three |
cookiesToRetain: ['brand'] |
/plans/?brand=three |
/plans, getUrlHash → 'abc' |
— | /plans/?hash=abc |
Googlebot on /plans?segment=business |
as above | static-/plans/?segment=business |
Why the trailing slash is forced
/plans and /plans/ are the same page to a user. Normalising to a trailing slash
collapses them into one cache entry instead of two.
Why utm_* is discarded
Marketing parameters change the URL but not the page. If they were part of the key, every campaign link would create its own cold cache entry. Only declared parameters count.
Why the bot key omits the URL hash
getCacheableUrls deliberately does not pass urlHash to the bot variant. Crawlers
should see one canonical static page per URL, not one per experiment bucket.
getParamString does double duty
The same private builder produces cache-key fragments (&-joined, no terminator) and
outbound Cookie header strings (space-joined, ;-terminated) via
appendQueryToCache and getCookieHeaderString.
cookiesToRetain is a privacy decision
Anything in cookiesToRetain becomes part of a shared cache key and is also forwarded to
the prerender service as a cookie. Never put a user identifier, token or session cookie in that list.
Cache States & Staleness
Logic in returnCachedPage, evaluated in exactly this order.
What is actually stored
{
htmlContent: "<!DOCTYPE html>...", // the finished page
apiData: { ... }, // captured window._hydratedData
statusCode: 200,
timeOfCache: 1730000000000, // Date.now() when written
isCurrentlyPrerendering: false, // single-flight marker
buildHash: "…" // release stamp
}
The state machine
| # | State | Detected by | Action | Visitor gets |
|---|---|---|---|---|
| 1 | Placeholder, in progress | No statusCode, isCurrentlyPrerendering: true |
next() only |
Client-side render |
| 2 | Placeholder, failed | No statusCode, isCurrentlyPrerendering: false |
Re-flag, warn, retry prerender in background | Client-side render |
| 3 | Old build | parsedData.buildHash !== buildHash |
Re-flag + background prerender (unless already flagged) | Client-side render |
| 4 | Expired | Date.now() - timeOfCache >= cacheSeconds * 1000 and not already flagged |
Re-flag + background prerender, then continue | The stale cached page |
| 5 | Geo page, human visitor | geoLocation set and user-agent is not a bot |
next() |
Client-side render |
| 6 | Shell server mode | skipReturnCachedPage truthy |
next() |
Client-side render |
| 7 | Fresh hit | None of the above | Set X-Prerender-Cache: true, send |
The cached page |
| 8 | Unreadable | Any thrown error, e.g. bad JSON | Warn, flag, background prerender, next() |
Client-side render |
An old build is never served — the HTML references asset files that no longer exist, so it would be visibly broken. An expired page is served, because slightly stale prices for a few seconds are better than a slow page. That is the stale-while-revalidate trade-off.
Two different clocks
Logical freshness — cacheSeconds
Default 600 (10 minutes). Compared against timeOfCache in application code. Controls when a
background refresh is triggered.
Physical lifetime — Redis TTL
Hard-coded: 86400 seconds (24h) for user pages, 259200 seconds (72h) for bot
pages. After that, Redis evicts the key and the URL is a cold miss again.
How stampedes are prevented
isCurrentlyPrerendering: truenext() — no extra prerender calls
When the prerender finishes, forwardToPrerenderServer overwrites the entry with
isCurrentlyPrerendering: false. If it fails,
handleInvalidPrerender clears the flag so a later request can retry.
Build Hash & Releases
A cached page contains <script src="/static/main.a1b2c3.js">-style references. Deploy a new
release and those filenames change. Serving the old cached HTML would point browsers at files that 404 — a
visibly broken page. The build hash is the guard against that.
1. The value
options.buildHash. Defaults to a fresh uuidv4() generated when
defaultOptions is first imported. Consumers normally pass their webpack bundle hash instead,
or supply getBuildHash(req) for a per-request lookup.
2. It must appear in the rendered HTML
getIsCacheable calls isCurrentBuild(html, buildHash), which is a plain
html.includes(buildIdentifier). If the string is absent the render is rejected and never
cached — with the log line "BuildHash not found in server render".
3. It is stored alongside the HTML
Written into the cache object as buildHash.
4. It is compared on every read
A mismatch in returnCachedPage means "cached by a previous release": regenerate in the
background and client-side render this request. The whole cache therefore self-invalidates after a deploy,
without anyone flushing Redis.
The default uuidv4() is generated per Node process. With multiple instances behind a load
balancer, each has a different hash, so instances constantly reject each other's cache entries and nothing
ever stays cached. Production consumers must pass a real, deployment-stable
buildHash.
Googlebot / Static Flow
Detection
isGooglebotRequest(headers) is deliberately blunt: it checks that a
user-agent header exists and contains the substring Googlebot. Separately,
returnCachedPage uses the isbot package for the broader "is this any crawler"
question in the geolocation branch.
Why bots get their own copy
Scripts removed
removeScriptTags strips every <script>…</script> block except those
with type="application/ld+json", so JSON-LD structured data survives while nothing executes.
Longer life
72 hours in Redis versus 24, because crawl budget is unpredictable and a crawler hitting a cold cache is a wasted crawl.
No hydration data
The bot entry stores only htmlContent, statusCode and
timeOfCache — no apiData, no buildHash. A crawler does not boot the
app, so asset filenames do not matter.
Not freshness-checked
returnStaticCachedPage reads the entry and sends it. There is no expiry comparison and no
background refresh: the bot cache is refreshed only as a side effect of a user-driven prerender for the
same URL.
Where the two caches are written
Both are written in the same place — forwardToPrerenderServer — from the same render. The bot
entry goes first, then the user entry.
scripts stripped · 72h
full HTML + apiData · 24h
If a URL is only ever visited by Googlebot and never by a user, its bot cache is never populated — the bot
branch only reads. Googlebot falls through to returnCachedPage or a cache miss, which
is what eventually warms it.
Install & Wire Up
1. Install
yarn add @vfuk/lib-web-prerender
# axios is a peerDependency — the consumer must provide it
yarn add axios
2. Mount it, in the right order
import cookieParser from 'cookie-parser'
import prerender from '@vfuk/lib-web-prerender'
import redis from './redis'
import clientSideRender from './clientSideRender'
app.use(cookieParser()) // REQUIRED: middleware reads req.cookies
app.use(prerender({
redis,
queryParamsToRetain: ['commitmentPeriod', 'segment', 'referrer'],
htmlReplacements: [],
cacheSeconds: 300,
prerenderServerUrl: process.env.PRERENDER_SERVICE_URL || 'http://localhost:3000',
utagSyncScript: process.env.TEALIUM,
buildHash: process.env.BUILD_HASH,
}))
app.get('/*', clientSideRender) // MUST come after prerender
prerender() either responds or calls next(). If it is mounted after your renderer,
the renderer will already have responded and the middleware does nothing. If cookie-parser is
mounted after it, the skip checks throw.
3. Provide a Redis client
import Redis from 'ioredis'
const redisNode = { port: process.env.REDIS_PORT, host: process.env.REDIS_URL }
const redis = new Redis.Cluster([redisNode])
redis.on('connect', () => console.info('Connected to Redis.'))
redis.on('error', (err, data) => console.info('An error occurred with Redis. ', err, data))
export default redis
Only four methods are used: exists, get, set(key, value, 'ex', seconds).
Anything implementing those will work.
4. Add the HTML markers
See The HTML Contract — without these the cache will refuse to store anything.
All Options Reference
Defaults come from src/middleware/constants/defaultOptions.js; your object is spread over them.
Required
| Option | Type | What it does |
|---|---|---|
prerenderServerUrl |
string |
Base URL of the prerender service. The library POSTs to <url>/render. If it does not
start with http, https:// is prepended. If falsy, the middleware disables
itself entirely.
|
redis |
object |
The cache store. In local/development a falsy value is replaced with an
in-memory MockRedis.
|
Caching & keying
| Option | Default | What it does |
|---|---|---|
cacheSeconds |
600 |
Logical freshness window. Past it, a background refresh is triggered but the stale page is still served. |
queryParamsToRetain |
[] |
Query parameters that create distinct cached pages. Also the only parameters forwarded to the prerender service in the URL being rendered. |
cookiesToRetain |
[] |
Cookies that create distinct cached pages. Also sent as a Cookie header on the prerender
request. Never list identity cookies here.
|
environment |
undefined |
Prefix on every cache key. Lets several environments safely share one Redis cluster. Not in
defaultOptions, so unset means no prefix.
|
getUrlHash |
() => Promise.resolve('') |
Async (req, res) hook returning an extra key dimension, appended as
hash=…. Applied to the user key only, never the bot key.
|
buildHash |
uuidv4() |
Release stamp. Must be present in the rendered HTML, and must be stable across instances. |
getBuildHash |
unset | Async (req) hook; if provided, its result overrides buildHash per request. |
Skipping & auth
| Option | Default | What it does |
|---|---|---|
skipChecker |
unset | Async (req, res). Truthy return means skip prerendering for this request. |
authCookiePrefix |
'eShop-auth' |
Prefix for the IDM token cookie <prefix>_p_id_token. Falls back to
process.env.AUTH_COOKIE_PREFIX then the literal default.
|
alwaysPrerenderRoutes |
unset | Array of exact req.url strings that bypass the cookie/journey skips. |
skipReturnCachedPage |
unset |
When truthy, cached pages are validated and refreshed but never sent — every request falls through to
next(). Used by shell-server style deployments.
|
HTML output
| Option | Default | What it does |
|---|---|---|
htmlReplacements |
[] |
Extra { placeholder, replacement, boilerplatePlaceholder? } entries applied
after the built-in replacements.
|
utagSyncScript |
'' |
Tealium utag.sync URL. When set, <script>var headUtagToReplace = true;</script>
becomes a real <script src>.
|
cdnDomain |
'' |
Outside NODE_ENV=local, every occurrence of prerenderServerUrl in the HTML is
replaced with this (or with an empty string if unset), so assets are not requested from the prerender
host.
|
Request behaviour
| Option | Default | What it does |
|---|---|---|
prerenderRequestTimeout |
30000 |
Axios timeout in ms for the render call. |
disableOutstandingRequiredRequests |
false |
When true, skips the "were all required API calls complete?" gate. Use only if your app does not use
the required flag on hydrate().
|
geoLocationConfig |
unset |
Array of { url, latitude, longitude, accuracy? }. The first entry whose url is
a substring of req.url is forwarded to the prerender service. accuracy
defaults to 10.
|
The HTML Contract
The library rewrites the HTML the prerender service returns by doing literal string replacements. Your app's
HTML must therefore contain the exact marker strings. These are not configurable defaults — they are
hard-coded in defaultReplacements.
| Marker your HTML must contain | Replaced with | Condition |
|---|---|---|
var _isPrerender = true; |
var _isPrerender = false; |
Always |
var _hydratedData = {}; |
var _hydratedData = <captured JSON>; |
Always |
var vfukTarget = {}; |
var vfukTarget = {data: … }; |
Only if the render captured vfukTargetData |
<script>var headUtagToReplace = true;</script> |
<script src="<utagSyncScript>"></script> |
Only if utagSyncScript is set |
Any occurrence of prerenderServerUrl |
cdnDomain (or '') |
When NODE_ENV !== 'local' |
Minimal template
<head>
<script>var headUtagToReplace = true;</script>
<script>
var _hydratedData = {};
var _isPrerender = ${this.isPrerender};
</script>
<!-- buildHash must appear somewhere in the document -->
</head>
_isPrerender
Set by the app to !!req.headers['x-prerender']. It is true only while the
prerender service is rendering, and is forced to false in the cached copy. Use it to suppress
analytics and other browser-only side effects during a server render.
_hydratedData
During the render, hydrate() writes API responses onto window._hydratedData. The
prerender service is instructed to copy it to window.prerenderData, and the library inlines
that JSON into the cached HTML so the client starts with the data already present.
When PRERENDER_BOILERPLATE=true, replacePlaceholders prefers each entry's
boilerplatePlaceholder if present. The only built-in that defines one is the hydration marker,
whose boilerplate form is the comment /* PRERENDER_INJECT */.
String.prototype.replace with a string pattern replaces only the first match. Only the
prerenderServerUrl entry uses a global RegExp. Markers must appear exactly once.
Using hydrate()
hydrate is the client-side half of the library. It wraps an API call so that the same call is made
once during the prerender and then not repeated in the browser.
import hydrate from '@vfuk/lib-web-prerender/lib/helpers/hydrate'
@action getCachedJourney = async () => {
const journey = await hydrate('journey', () => {
return this.simoService[this.journeyToStart](this.journeyProps) || {}
})
this.loadJourney(journey)
}
Signature
hydrate(nameOfData: string, dataCall: () => Promise<any>, isRequired = false)
nameOfData
The key under window._hydratedData. Must be unique and stable — it is the identity of the
cached payload.
dataCall
Executed only when there is no cached value for that key.
isRequired
When true, increments outstandingRequiredRequests before the call and decrements after. If it
is not back to 0 when the snapshot is taken, the whole render is rejected as incomplete.
Behaviour
window._hydratedData.journey set?fn, store the result under the key, resolve itisCachedData
When a cached object is returned it is spread with { isCachedData: true, ...cachedData }
so consuming code can tell it came from the prerender. Cached arrays are returned unchanged (adding a
property to an array would break it).
The related helpers
requiredForPrerender(dataCall)
The counter mechanics of hydrate without the caching. Use it to mark work as required for a
valid render when there is no payload to store.
prerenderStaticPage()
Call it on a page that makes no API calls. It initialises
_hydratedData.outstandingRequiredRequests = 0 during a prerender so the cacheability gate
passes instead of seeing undefined.
lazyHydrate({ module, fallback, errorComponent })
A React helper for code-split components. Before the dynamic import resolves, it re-renders the static HTML the prerender already produced for that component ID, so there is no flash of empty space.
useLoadDynamicImport(module)
The hook behind lazyHydrate. Calls module.getComponent() and returns
{ dynamicComponent, isLoading, error }.
The package's default export is the middleware only (src/index.js re-exports
middleware/prerender). The client helpers are not re-exported from the root, so they are imported
by their built path under lib/helpers/….
Running It Locally
Working on the library itself
| Command | What it does |
|---|---|
yarn test | Jest with NODE_ENV=test. Unit + integration tests. |
yarn build | Modern build to lib/ then legacy build to lib/legacy/, then copies package.json, .npmignore, .npmrc. |
yarn build:dev | Webpack dev build via config/webpack/dev.config.js. |
yarn dev | Nodemon watch loop around yarn start. |
yarn jsdocs:build | Generates API HTML docs with documentation. |
build:dev, start, dev, jsdocs:build,
build:docs and docs:serve point at config/webpack/,
src/package, lib/main.js and docs/website — none of which are present
in the repo today. In practice the working scripts are test and build.
sonar-project.properties is likewise still the untouched template
(lib-web-your-package-name, sonar.sources=./src/package).
Testing your change inside a shop
.linkrc.js configures the internal linking tool: it builds the library with
yarn build, links lib/ into a consumer whose name starts with
web-shop- (flattening the lib directory name), copies
package.json across, cleans both ends, and watches ./src for
js/json/ts/jsx changes.
Running without Redis
With NODE_ENV set to local or development and no
redis option, MockRedis is injected — a plain in-memory object with
exists, get and set. Good enough to exercise caching without the Redis
Docker image. It ignores TTLs and dies with the process.
Local URL rewriting
buildPrerenderUrl hard-codes the app endpoint to http://localhost:8000 when
NODE_ENV is local, development or test — so the prerender
service is told to render your local app rather than a deployed one. Set
PRERENDER_BOILERPLATE=true and PRERENDER_APP_ENDPOINT to override that.
Ordered File Map
Read the files in this order and the codebase will make sense on the first pass. The ordering follows the path a single HTTP request takes, not the alphabetical layout on disk.
Almost every helper is a folder containing three files: index.js (a one-line re-export),
thing.js (the implementation, one exported function), and thing.test.js. So
import x from '../getCacheableUrl' resolves through the folder's index.js. The
index.js files carry no logic and can be ignored while reading.
| # | File | One-line job | Importance |
|---|---|---|---|
| 1 | src/index.js | Package entry point; re-exports the middleware. | Trivial |
| 2 | src/middleware/prerender.js | The orchestrator. Reading only this file tells you 80% of the story. | Critical |
| 3 | constants/defaultOptions.js | Every default value in one place. | High |
| 4 | helpers/fixPrerenderServerUrl/ | Prepends https:// when missing. | Low |
| 5 | helpers/initShouldSkipPrerender/ | The privacy and safety gate. | Critical |
| 6 | helpers/getSessionCookie/ | Finds the IDM session cookie. | Medium |
| 7 | helpers/getCacheableUrls/ | Produces both cache keys. | High |
| 8 | helpers/getCacheableUrl/ | Builds one cache key. | High |
| 9 | helpers/getParamString/ | Shared key-fragment / cookie-header builder. | Medium |
| 10 | helpers/getGeoLocationOverride/ | Matches a URL to a lat/long config. | Low |
| 11 | helpers/isGooglebotRequest/ | User-agent substring check. | Low |
| 12 | helpers/returnStaticCachedPage/ | Serves the bot cache. | Medium |
| 13 | helpers/returnCachedPage/ | The cache-state machine. | Critical |
| 14 | helpers/forwardToPrerenderServer/ | Calls the render service and writes both caches. | Critical |
| 15 | helpers/buildPrerenderUrl/ | Works out what URL to render and where to POST. | High |
| 16 | helpers/getPrerenderableUrl/ | Path + filtered query for the render. | Medium |
| 17 | helpers/getFilteredQueryString/ | Keeps only declared query params. | Medium |
| 18 | helpers/getRequestOptions/ | Builds the axios request. | High |
| 19 | .../getIsCacheable/ | Three-part correctness gate on the render. | Critical |
| 20 | helpers/isCurrentBuild/ | html.includes(buildHash). | Low |
| 21 | helpers/replacePlaceHolders/ | Applies the replacement list. | High |
| 22 | helpers/defaultReplacements/ | Defines the built-in HTML contract. | High |
| 23 | helpers/removeScriptTags/ | Strips scripts for the bot copy. | Medium |
| 24 | helpers/cachePageResponse/ | The only Redis write path. | Medium |
| 25 | helpers/setIsCurrentlyPrerenderingInCache/ | Sets the single-flight marker. | High |
| 26 | helpers/handleInvalidPrerender/ | Clears the marker after a failure. | Medium |
| 27 | utils/MockRedis/ | In-memory Redis stand-in for local dev. | Low |
| 28 | src/helpers/hydrate/ | Client-side data capture & reuse. | Critical |
| 29 | src/helpers/requiredForPrerender/ | Marks work as required for a valid render. | Medium |
| 30 | src/helpers/prerenderStaticPage/ | Makes API-free pages cacheable. | Medium |
| 31 | src/helpers/lazyHydrate/ | Reuses prerendered HTML for lazy React components. | Medium |
1. Entry Points
src/index.js1 line
export { default } from './middleware/prerender'
Simply: the front door. It says "the thing this package gives you is the prerender middleware".
Technically: the package's main is index.js relative to the
published lib/, so this compiles to lib/index.js. Note that only the middleware
is re-exported — the client-side helpers are reached by deep path.
src/middleware/index.js1 line
export { default } from './prerender'
Simply: the same thing one level down, so import … from './middleware' works.
2. The Core Middleware
src/middleware/prerender.jsMost important file
Simply: the manager. It does no detailed work itself — it asks the right specialist at the right moment and decides whether to answer the visitor or hand the job back to the website.
Technically: a factory prerender(customOptions) that closes over resolved
options and returns an async Express middleware.
Setup phase (runs once)
{ ...defaultOptions, ...customOptions, ...fixPrerenderServerUrl(...) }— note the URL fix is spread last, so it always wins.- In
local/development,options.redis ||= new MockRedis(). MarkedTODO: deprecatedpending the boilerplate migration. initShouldSkipPrerender(options)is called once and its returned closure reused.
Per-request phase
await shouldSkipPrerender(req, res)→next()and return.getBuildHash/getUrlHashresolution.getCacheableUrls→ both keys.getGeoLocationOverride, only whengeoLocationConfigis an array.- Build
prerenderOptions, carryingreq/res/next. - The three-way branch on Googlebot / cache hit / miss.
Why it is written this way
- One options object passed everywhere keeps helper signatures uniform and makes them trivially testable.
- The miss branch does not await
forwardToPrerenderServer, which is what keeps latency flat on a cold cache. - Two
existscalls, notget— the branch only needs to know whether a key is present; the body is fetched inside the chosen handler.
src/middleware/constants/defaultOptions.jsHigh
Simply: the sensible-defaults sheet. Anything a consumer does not specify comes from here.
Technically: a frozen-by-convention object literal. The one live expression is
buildHash: uuidv4(), evaluated at import time — i.e. once per Node process. See the warning in
Build Hash & Releases.
Note what is absent: environment, skipChecker,
getBuildHash, alwaysPrerenderRoutes, geoLocationConfig,
skipReturnCachedPage, authCookiePrefix and redis have no defaults
and are simply undefined unless supplied.
3. Setup & Skip Helpers
helpers/initShouldSkipPrerender/initShouldSkipPrerender.jsCritical
Simply: the bouncer. It answers one question — "is it safe and worthwhile to serve this visitor a shared page?" If not, the library stands aside.
Technically: a curried function; the outer call binds options at start-up, the inner
async call evaluates per request. Returns true to skip.
Evaluation order: hard skips → alwaysPrerenderRoutes override →
[hasPrerenderHeader, hasJourney, skipChecker?] combined with .some(Boolean).
Full rule table in When Prerender Is Skipped.
Why it matters: this file is the privacy boundary. A bug here means a logged-in customer's page could be cached and served to a stranger. Treat every change as security-relevant.
helpers/getSessionCookie/getSessionCookie.jsMedium
Simply: finds the cookie that says whether someone is signed in.
const cookiePrefix = authCookiePrefix || process.env.AUTH_COOKIE_PREFIX || 'eShop-auth'
return req.cookies[`${cookiePrefix}_p_id_token`] || req.cookies.Session || {}
Returning {} on failure is deliberate: the caller reads
session.assuranceLevel, and undefined >= 3 is false, so an
absent session correctly means "not logged in" rather than throwing. It has no test file — the behaviour
is covered through initShouldSkipPrerender's tests.
helpers/fixPrerenderServerUrl/fixPrerenderServiceUrl.jsLow
Simply: if someone configured prerender.internal instead of
https://prerender.internal, this adds the missing bit.
Returns { prerenderServerUrl } rather than a bare string so it can be spread over the
options object in one expression. Note the filename (…ServiceUrl.js) differs from its folder
and its test file (…ServerUrl.test.js) — harmless, but confusing when grepping.
utils/MockRedis/MockRedis.jsLow
Simply: a pretend Redis, so you can develop without running the real one.
export default class MockRedis {
store = {}
exists = async (key) => !!this.store[key]
set = async (key, value) => (this.store[key] = value)
get = async (key) => this.store[key]
}
It ignores the 'ex' TTL arguments entirely and is per-process, so nothing expires and nothing
is shared. Only injected when NODE_ENV is local or development.
4. Cache Key Helpers
Concepts and worked examples are in Cache Keys Explained; this is the file-level view.
helpers/getCacheableUrls/getCacheableUrls.jsHigh
Simply: makes the two labels — one for people, one for Google.
Technically: calls getCacheableUrl twice. The bot call passes
isBot: true and deliberately omits urlHash, so crawlers get one canonical entry
per URL rather than one per experiment variant.
helpers/getCacheableUrl/getCacheableUrl.jsHigh
Simply: writes the label for one page.
Technically: splits req.url on ?, parses the query with
qs, forces a trailing slash on the path, then appends — in this order — the URL hash, the
retained query params, and the retained cookies. Result:
`${isBot ? 'static-' : ''}${environment}${urlWithoutQuery}${queryString}`
Fragments follow the order of the queryParamsToRetain / cookiesToRetain arrays,
not the order in the incoming URL. That is what makes keys stable — but it also means
reordering those arrays invalidates the entire cache.
qs is used but is not declared in package.json — it resolves transitively
(via Express). Worth knowing if dependencies are ever pruned.
helpers/getParamString/getParamString.jsMedium
Simply: one small string-builder used for two different jobs.
Technically: a private _getParamString with an
isForCacheKey switch that selects the separator and terminator, exposed as two named
functions:
| Export | Separator | Terminator | Used by | Example |
|---|---|---|---|---|
appendQueryToCache | & | none | getCacheableUrl | segment=business&brand=three |
getCookieHeaderString | space | ; | getRequestOptions | segment=business; brand=three; |
A key that is present but empty contributes just its name — the presence of a parameter can be meaningful even without a value.
helpers/getFilteredQueryString/getFilteredQueryString.jsMedium
Simply: throws away the query parameters nobody declared as important, and returns what
is left with a ? in front.
Very close to appendQueryToCache but separate because it starts a fresh string and prefixes
?. Returns '' when there is no query string or nothing to retain. Used for the
URL sent to the prerender service, not for the cache key.
helpers/getGeoLocationOverride/getGeoLocationOverride.jsLow
Simply: "if this page is about a place, tell the renderer to pretend it is standing there."
Finds the first geoLocationConfig entry whose url is a substring of
req.url, returns it with accuracy defaulted to 10, and returns
undefined when nothing matches. The result is forwarded in the prerender request body.
helpers/isGooglebotRequest/isGooglebotRequest.jsLow
Simply: "does the visitor say it is Google?"
return !!headers['user-agent'] && headers['user-agent'].includes('Googlebot')
A substring check on a spoofable header. That is acceptable because the worst outcome is that someone
receives a script-free page. Other crawlers do not get the static branch — for the broader question,
returnCachedPage uses the isbot package instead.
5. Serving Helpers
helpers/returnCachedPage/returnCachedPage.jsCritical
Simply: the quality inspector on the hot counter. It picks up the ready-made page and runs through a checklist before handing it over — is it finished? is it from the current menu? is it too old? Depending on the answers it serves it, orders a replacement, or waves the visitor through to the kitchen.
Technically: reads and JSON-parses the entry, then evaluates eight states in a fixed order. Full table in Cache States & Staleness.
The three things it can do
next()— let the app render client-side.res.status(...).send(...)withX-Prerender-Cache: true.- Fire
forwardToPrerenderServer(options)without awaiting — a background refresh.
Details that are easy to miss
-
The absence of
statusCodeis how a placeholder is recognised. The single-flight marker written bysetIsCurrentlyPrerenderingInCachehas onlyisCurrentlyPrerenderingandtimeOfCache, so "no status code" means "no real page here yet". - The expiry branch does not return. It triggers the refresh and then falls through to send the stale page — that is the stale-while-revalidate behaviour.
-
The geolocation branch inverts the usual logic: if the page has a geolocation override
and the visitor is not a bot (checked with
isbot), the cached page is not served. A location-specific cached page is for crawlers; real users get a live render. - The status code is replayed. A cached 404 is sent as a 404, not a 200.
-
The file begins with stray
;;. Cosmetic, harmless, but it is really there.
helpers/returnStaticCachedPage/returnStaticCachedPage.jsMedium
Simply: hands Google the script-free copy. No checks, no timers — read it and send it.
Technically: redis.get(cacheableBotUrl) → parse → set
X-Prerender-Cache: true → res.status(...).send(...). Any error is warned and
next() is called.
Deliberately much simpler than returnCachedPage: no freshness check, no build-hash check, no
background refresh. A crawler does not execute the page, so a slightly old static copy is harmless, and
its Redis TTL of 72h is the only lifetime control.
6. Prerender Request Helpers
helpers/forwardToPrerenderServer/forwardToPrerenderServer.jsCritical
Simply: the cook. It asks the rendering service for a fully-built page, checks the result is good, tidies up the HTML, and puts two copies on the counter — one for people, one for Google.
Step by step
buildPrerenderUrl→{ requestUrl, urlToRender }.getRequestOptions→ the axios config.await axios(options).getIsCacheable(response, buildHash, disable…). If false →handleInvalidPrerenderand stop; nothing is cached.replacePlaceholders(response.data.content, [...defaultReplacements(...), ...htmlReplacements])— consumer replacements run last, so they can override.- Write the bot entry:
removeScriptTags(completeHtml), status, timestamp, TTL259200. - Write the user entry: full HTML,
apiData, status, timestamp,isCurrentlyPrerendering: false,buildHash, TTL86400.
await
Every call site — the miss branch in prerender.js and three branches in
returnCachedPage — invokes it as fire-and-forget. That is intentional (nobody waits for a
render) but it means its rejections are swallowed by its own internal try/catch, and
failures are visible only through [Prerender] log lines.
It writes to Redis directly with redis.set(...) rather than going through
cachePageResponse, which is why the TTLs appear here as literals.
helpers/buildPrerenderUrl/buildPrerenderUrl.jsHigh
Simply: works out two addresses — where to send the render request, and which page the renderer should load.
requestUrl: `${prerenderServerUrl}/render`
urlToRender: `${appServerEndpoint}${urlToPrerender}`
appServerEndpoint is decided by environment:
| Condition | App endpoint used |
|---|---|
NODE_ENV is local, development or test | http://localhost:8000 (hard-coded) |
PRERENDER_BOILERPLATE === 'true' | PRERENDER_APP_ENDPOINT ?? prerenderServerUrl |
| Otherwise | prerenderServerUrl |
The boilerplate check runs after the local check, so it wins when both apply. The default case —
telling the prerender service to render prerenderServerUrl itself — is why
defaultReplacements later rewrites that host to cdnDomain in the cached HTML.
helpers/getPrerenderableUrl/getPrerenderableUrl.jsMedium
Simply: the page path plus only the query parameters that matter.
Splits req.url and re-joins it with getFilteredQueryString(...). This keeps the
rendered page aligned with the cache key: if a parameter is not worth its own cache entry, it is not sent
to the renderer either.
helpers/getRequestOptions/getRequestOptions.jsHigh
Simply: fills in the form that is sent to the rendering service.
{
method: 'post',
url: requestUrl, // <prerenderServerUrl>/render
followRedirect: false,
withCredentials: true,
headers: { Cookie?: '…' }, // only if cookiesToRetain is non-empty
timeout: prerenderRequestTimeout,
data: {
renderType: 'html',
javascript: 'window.prerenderData=window._hydratedData',
url: urlToRender,
geoLocation?: { … },
},
httpsAgent: new HttpsAgent({ rejectUnauthorized: false }),
validateStatus: () => true,
}
The three lines worth understanding
-
javascript: a snippet the headless browser runs after the page settles. It copies the app's accumulated_hydratedDataontoprerenderData, which is how the captured API responses come back in the response body. -
validateStatus: () => true: axios never throws on an HTTP status.getIsCacheableowns that judgement instead — that is how a legitimate 404 can be cached. -
rejectUnauthorized: false: accepts self-signed certificates on the internal prerender host. A newHttpsAgentis constructed per request, so the keep-alive poolingagentkeepaliveprovides is not actually shared between requests.
followRedirect is not an axios option (axios uses maxRedirects), so it has no
effect. validateStatus is also spelled without the axios validateStatus casing
issue — it is correct — but the property axios reads is validateStatus only in v1; confirm
against the pinned axios version before relying on it.
…/forwardToPrerenderServer/helpers/getIsCacheable/getIsCacheable.jsCritical
Simply: the taste test. Three questions, and a "no" to any of them means the page is thrown away rather than stored.
| Check | Passes when | Why it exists |
|---|---|---|
safeToCache |
Status 200–299, or exactly 404 | A 500 or 302 is not a page. A 404 is a real, cacheable page. |
requiredCallsSuccessful |
prerenderData.outstandingRequiredRequests === 0, or status 404, or the option is disabled |
A non-zero counter means a required API call had not returned when the snapshot was taken — the page is incomplete. |
isCurrentBuild |
HTML contains the current buildHash |
Guards against caching a render produced by a different release. |
Each failure logs a distinct [Prerender] warning, which makes this the single most useful file
to grep for when pages mysteriously never cache.
helpers/isCurrentBuild/isCurrentBuild.jsLow
const isCurrentBuild = (html, buildIdentifier) => html.includes(buildIdentifier)
Simply: "does this page mention the current version number?" One line, no test file, but load-bearing — it is the reason a deploy invalidates the cache.
'anything'.includes('') is true. An empty or undefined-coerced
buildHash makes this check pass unconditionally, silently disabling release invalidation.
7. HTML Transform Helpers
helpers/defaultReplacements/defaultReplacements.jsHigh
Simply: the standing list of find-and-replace edits every cached page gets.
Technically: builds an array of { placeholder, replacement } — two always,
three more conditionally. Full table in The HTML Contract.
Why each edit exists
-
_isPrerender → false: the cached HTML was produced during a prerender but will be served to real browsers, which must run their analytics and browser-only code. -
_hydratedData → JSON: inlines the captured API responses so the client boots without repeating them. This is the pay-off of the whole hydrate mechanism. -
vfukTarget: same idea for Adobe Target personalisation data, added only when the render capturedvfukTargetData. -
prerenderServerUrl → cdnDomain: the only global RegExp in the list. Without it, cached pages would ask browsers to fetch assets from the internal prerender host. Skipped whenNODE_ENV === 'local'. -
headUtagToReplace: swaps a placeholder script tag for the real Tealiumutag.sync.js, which must load synchronously in<head>.
helpers/replacePlaceHolders/replacePlaceholders.jsHigh
Simply: actually performs the edits, one after another.
replacementList.forEach((item) => {
let placeholderKey = item.placeholder
if (process.env.PRERENDER_BOILERPLATE === 'true') {
placeholderKey = item.boilerplatePlaceholder || item.placeholder
}
amendedHtml = amendedHtml.replace(placeholderKey, item.replacement)
})
The PRERENDER_BOILERPLATE branch is marked TODO: deprecated — it exists so the
same library version works against both the old markup and the new boilerplate markup during migration.
String patterns replace a single occurrence. Duplicate markers in your template will leave the second
one un-replaced — typically showing up as an empty _hydratedData in the browser.
Folder is replacePlaceHolders (capital H) but the file is replacePlaceholders.js
(lower h). Case-sensitive filesystems care; macOS does not.
helpers/removeScriptTags/removeScriptTags.jsMedium
Simply: takes the interactive bits out of the copy meant for Google, but keeps the machine-readable product information.
html.match(/<script(?:.*?)>(?:[\S\s]*?)<\/script>/gi)
// remove each match unless it contains 'application/ld+json'
Regex-based rather than DOM-based because it must be fast and there is no parser available server-side
here. Wrapped in try/catch returning the original HTML on failure — a bot page with scripts is
better than no bot page.
Keeping JSON-LD is the entire point: that is where structured product, price and breadcrumb data lives, and it is what rich search results are built from.
8. Cache Write Helpers
helpers/cachePageResponse/cachePageResponse.jsMedium
Simply: writes an object into Redis as text, and refuses to crash if Redis is unhappy.
await redis.set(cacheableUrl, JSON.stringify(cachedObject), 'ex', 86400)
Swallows errors with a warning — a failed cache write must never break a response. Note the 24h TTL is hard-coded here too, so a placeholder marker also lives for 24h if never overwritten.
helpers/setIsCurrentlyPrerenderingInCache/…jsHigh
Simply: puts up a "being cooked" sign so several visitors do not all order the same dish at once.
Technically: two modes:
-
With an existing
cacheObject— sets the flag on it and rewrites it, so the currently-cached HTML survives while the refresh runs. On failure it falls back to writing a bare marker. -
Without one — writes a bare marker
{ isCurrentlyPrerendering, timeOfCache }. This is the object whose missingstatusCodetellsreturnCachedPage"no real page here yet".
The distinction matters: a cold miss must not serve anything, but an expiring page must keep serving while it refreshes.
helpers/handleInvalidPrerender/handleInvalidPrerender.jsMedium
Simply: takes the "being cooked" sign down when the cooking failed, so someone can try again.
Reads the entry, sets isCurrentlyPrerendering = false, writes it back, then sets its local
redis variable to null (a no-op for the caller — reassigning a parameter does not
affect the caller's reference).
If the entry is missing or unparseable, JSON.parse(null) returns null and
assigning a property to it throws. Because every call site invokes it inside a
try or without awaiting, the throw is contained — but the flag then stays set until the
Redis TTL expires, which is the classic cause of "this URL stopped caching and won't recover".
9. Client-Side Helpers
These ship in the same package but run in the browser (and inside the headless browser during a render), not in Express. They are the other half of the contract.
src/helpers/hydrate/hydrate.jsCritical
Simply: "ask for this data, but if the prerender already fetched it, just use that."
Technically: returns a Promise. If
window._hydratedData[nameOfData] exists, resolves it immediately — arrays as-is, objects spread
with { isCachedData: true, ... }. Otherwise it initialises
_hydratedData and the outstandingRequiredRequests counter, optionally increments
that counter, awaits dataCall(), stores the result under the key, decrements, and resolves.
Why this exists: without it, a prerendered page would arrive fully drawn and then immediately re-request every API on boot — the visitor would see a flash of loading states and the cache would have saved nothing but the first paint. Full usage guidance in Using hydrate().
Uses the new Promise(async (resolve, reject) => …) anti-pattern. It works because the body
is wrapped in try/catch, but a throw outside that catch would be lost.
src/helpers/requiredForPrerender/requiredForPrerender.jsMedium
Simply: "the page is not finished until this has come back."
The counter half of hydrate with no caching: increment, await, decrement. Use it for work the
render must wait for but whose result should not be stored — and remember that if it rejects, the counter
is never decremented, so the render is permanently judged incomplete and the page will never cache.
src/helpers/prerenderStaticPage/prerenderStaticPage.jsMedium
Simply: for pages with no data to fetch, this says "nothing to wait for — go ahead and cache me."
if (window._isPrerender) {
window._hydratedData = window._hydratedData || {}
window._hydratedData.outstandingRequiredRequests =
window._hydratedData.outstandingRequiredRequests || 0
}
Without it, a purely static page never touches hydrate, so
outstandingRequiredRequests is undefined,
undefined === 0 is false, and getIsCacheable rejects the render. A tiny file that
fixes a confusing "my static page won't cache" failure.
src/helpers/lazyHydrate/lazyHydrate.jsMedium
Simply: for parts of the page that load their code separately, this shows the version the prerender already drew instead of an empty box while the code downloads.
Technically: a higher-order component factory. Given
{ module: { scope, name, getComponent }, fallback, errorComponent } it:
- Computes
compId = `${scope}-${name}`and grabsdocument.getElementById(compId)at factory time — the prerendered markup. - Returns a component that calls
useLoadDynamicImport(module). - While loading: if prerendered HTML was found, renders it back via
dangerouslySetInnerHTML; otherwise renders the suppliedfallback. - On error: renders
errorComponentor a plain<div>Error</div>. - Once resolved: renders the real component inside
<div id={compId}>, which is what makes the ID available for the next prerender.
The wrapper div id is the whole mechanism — it is the anchor that links a prerendered
fragment to the component that will eventually replace it.
…/lazyHydrate/helpers/useDynamicImport/useLoadDynamicImport.jsLow
Simply: downloads the component's code and reports whether it is ready.
A hook that calls module.getComponent() in an effect keyed on [scope, name] and
returns { dynamicComponent, isLoading, error }. Stores the component with
setDynamicComponent(() => module.default) — the functional form, because React would
otherwise treat a function value as a state updater.
File Importance Tiers
Not all 31 modules deserve equal attention. If you have twenty minutes, read Tier 1. If you have an hour, read Tiers 1 and 2. Tier 4 you can safely skim forever.
Tier 1 — Read these first
CriticalChange these and you change the behaviour of every Vodafone shop.
middleware/prerender.js
The orchestrator. Every request passes through it and it names every other participant.
initShouldSkipPrerender
The privacy boundary. Decides who may be served shared HTML. Security-relevant.
returnCachedPage
The eight-state cache machine. Nearly all cache behaviour questions are answered here.
forwardToPrerenderServer
The only place a render is requested and the only place both caches are written.
getIsCacheable
The three-part gate. If pages never cache, the reason is logged here.
helpers/hydrate
The client-side contract. Without it, prerendering saves the first paint and nothing else.
Tier 2 — Read when you touch caching
Highconstants/defaultOptions.js— every default in one glance.getCacheableUrls+getCacheableUrl— cache identity.setIsCurrentlyPrerenderingInCache— the single-flight marker, and its two modes.buildPrerenderUrl— the environment branching that trips people up locally.getRequestOptions— the exact contract with the prerender service.defaultReplacements+replacePlaceHolders— the HTML contract.
Tier 3 — Small, focused, occasionally relevant
MediumgetParamString,getFilteredQueryString,getPrerenderableUrl— string plumbing.getSessionCookie— auth cookie lookup.returnStaticCachedPage,removeScriptTags— the bot path.cachePageResponse,handleInvalidPrerender— Redis writes and failure recovery.requiredForPrerender,prerenderStaticPage,lazyHydrate— client helpers.
Tier 4 — One-liners and scaffolding
Low- Every
index.jsre-export — no logic at all. isCurrentBuild,isGooglebotRequest,fixPrerenderServerUrl,getGeoLocationOverride.MockRedis,useLoadDynamicImport.- Config:
babel.config.js,legacy.babel.config.js,.linkrc.js,renovate.*,cicd/*.yaml, jest tooling.
prerender.js → initShouldSkipPrerender → getCacheableUrl →
returnCachedPage → forwardToPrerenderServer → getIsCacheable. Six files,
and you will understand the system.
Where To Make A Change
A lookup table from "I want to…" to the file to open.
| I want to… | Open | Watch out for |
|---|---|---|
| Add a new configuration option | constants/defaultOptions.js plus the consuming helper |
Add it to the defaults even if the default is falsy, so it is discoverable. |
| Change who gets a cached page | initShouldSkipPrerender |
Privacy-critical. Any loosening needs review; prefer a consumer skipChecker. |
| Make a query param or cookie affect the cache | Nothing here — the consumer's queryParamsToRetain / cookiesToRetain |
Every added dimension multiplies cache entries and lowers hit rate. |
| Change the cache key format | getCacheableUrl |
Invalidates every existing entry across all shops on deploy. Coordinate it. |
| Change how long pages live | cacheSeconds for freshness; the literals in forwardToPrerenderServer and cachePageResponse for Redis TTL |
The TTLs (86400 / 259200) are hard-coded in three places. |
| Add a new HTML replacement for one shop | The consumer's htmlReplacements option |
Consumer replacements run after the defaults, so ordering is on your side. |
| Add a replacement for every shop | defaultReplacements |
Now every consumer's HTML must contain the marker or the edit silently does nothing. |
| Change what the prerender service is asked to do | getRequestOptions |
The javascript snippet and renderType are a contract with that service. |
| Change what counts as a cacheable render | getIsCacheable |
Loosening it risks caching broken pages; tightening it can stop caching altogether. |
| Change bot handling | isGooglebotRequest, returnStaticCachedPage, removeScriptTags |
Removing JSON-LD would break rich results. Keep the ld+json exemption. |
| Fix a cache-stuck-in-progress bug | setIsCurrentlyPrerenderingInCache + handleInvalidPrerender |
A flag that is set but never cleared is the usual cause. Consider a timestamp on the flag. |
| Add a client-side data helper | src/helpers/ |
Remember it is not re-exported from the package root — document the deep import path. |
| Release your change | PR to main with [major]/[minor]/[patch] in the title |
The version-bump block in cicd/build-legacy.yaml is currently commented out — verify how versioning is actually applied. |
It is a shared library. A change ships to every Vodafone shop that bumps the version. Prefer adding an
option over changing a default, and prefer a consumer hook (skipChecker,
getUrlHash, htmlReplacements) over new library-wide behaviour.
Key Data Shapes
The four objects that everything else is expressed in terms of.
1. The user cache entry
{
htmlContent: string, // finished HTML, placeholders already replaced
apiData: object, // captured window._hydratedData
statusCode: number, // 200 or 404
timeOfCache: number, // Date.now()
isCurrentlyPrerendering: boolean, // single-flight marker
buildHash: string // release stamp
}
// Redis TTL: 86400s
2. The bot cache entry
{
htmlContent: string, // scripts stripped, JSON-LD kept
statusCode: number,
timeOfCache: number
}
// Redis TTL: 259200s — note: no apiData, no buildHash, no flag
3. The placeholder / in-progress marker
{
isCurrentlyPrerendering: true,
timeOfCache: number
}
// The MISSING statusCode is the signal that there is no real page yet.
4. The prerender service response
{
status: number,
data: {
content: string, // raw rendered HTML
prerenderData: {
outstandingRequiredRequests: number, // must be 0 to cache
vfukTargetData?: object, // optional Adobe Target payload
[key: string]: any // whatever hydrate() stored
}
}
}
How they relate
content + prerenderData
gate
prerenderData inlined into HTML
_hydratedData, hydrate() skips the networkFile Structure Tree
Red = read this first.
index.js files are one-line re-exports; *.test.js files are omitted for readability
except where noted.
config/webpack/ and docs/website/ are named by npm scripts but are not in the
repository. src/package/, referenced by sonar-project.properties and the jsdocs
script, is also absent.
Environment Variables
Read directly by library code — not passed as options.
| Variable | Read in | Effect |
|---|---|---|
NODE_ENV |
prerender.js, buildPrerenderUrl, defaultReplacements |
local/development: inject MockRedis when no client given.
local/development/test: render against
http://localhost:8000. local: skip the CDN domain rewrite.
|
AUTH_COOKIE_PREFIX |
getSessionCookie |
Fallback auth cookie prefix when authCookiePrefix is not passed. Final fallback is eShop-auth. |
PRERENDER_BOILERPLATE |
replacePlaceholders, buildPrerenderUrl |
When the string 'true': prefer boilerplatePlaceholder markers, and use
PRERENDER_APP_ENDPOINT as the render target.
|
PRERENDER_APP_ENDPOINT |
buildPrerenderUrl |
Explicit app endpoint for the boilerplate path. Falls back to prerenderServerUrl. |
PRERENDER_BOILERPLATE is compared with === 'true'. Setting it to
1, TRUE or yes has no effect.
Variables such as PRERENDER_SERVICE_URL, REDIS_URL, TEALIUM and
BUILD_HASH appear in the README examples, but they are read by the consuming app and passed
in as options. The library never reads them.
Dependencies
Runtime dependencies
| Package | Version | Used for |
|---|---|---|
agentkeepalive | ^4.6.0 | HttpsAgent in getRequestOptions, with rejectUnauthorized: false. |
isbot | ^5.1.26 | Broad crawler detection in the geolocation branch of returnCachedPage. |
uuid | ^11.1.0 | The default buildHash. |
@babel/plugin-transform-runtime | ^7.26.10 | Listed as a runtime dependency because the compiled output references the Babel helpers. |
Peer dependency
axios ^1.8.4 — the consumer must install it. This avoids two copies of axios in the same app and
lets the consumer control the version.
Undeclared but used
qs
Imported by getCacheableUrl and getFilteredQueryString but absent from
package.json. It resolves today because Express depends on it. Worth declaring explicitly.
jest.config.js also references @swc/jest as its transform and
jestTrxProcessor.js requires jest-trx-results-processor and mkdirp, none
of which appear in devDependencies. The active test run uses babel-jest.
Notable devDependencies
- jest 29 + jest-environment-jsdom
- supertest 7 (integration tests)
- express 4 + cookie-parser (test harness)
- @testing-library/react 12 + react 16.14
- babel 7 CLI, preset-env, preset-react
- prettier 2, eslint 5, standard 12
- ncp, rimraf, cross-env
lazyHydrate is JSX, so React is needed to build and test it — but only as a dev dependency, since
consumers bring their own React. React 16 keeps compatibility with the older shops.
Build & Babel
Two builds from one source
src/babel.config.js→ lib/
legacy.babel.config.js→ lib/legacy/
postbuild: copy package.json, .npmignore, .npmrc into lib/npm publish from ./libbabel.config.js — the modern build
- Presets:
preset-envtargeting> 0.5%, last 2 versions, not dead, pluspreset-reactfor the JSX inlazyHydrate. - Plugins:
class-properties(used byMockRedis),module-resolveraliasing@src→./src,transform-runtime. -
Override for
./src/middleware: compiled fornode: '18.20'instead of browsers, plusnullish-coalescing. This is the key detail — the middleware is server code and should not be down-levelled for old browsers. sourceType: 'unambiguous'so files are treated as modules only when they use import/export.
config/legacyBabel/legacy.babel.config.js
The same plugins but preset-env with no targets — i.e. transpile everything to ES5 — and no Node
override. The output lands in lib/legacy/ for consumers that still need maximal compatibility.
What is excluded from the build
--ignore mocks/,src/**/**/**/*.test.js,.md
Mocks, tests and markdown are not published.
Testing
yarn test # cross-env NODE_ENV=test jest
Coverage policy — src/tools/jest/jest.config.js
| Setting | Value |
|---|---|
| Thresholds | 90% statements, branches, functions, lines |
| Collected from | src/helpers/**, src/middleware/helpers/**, src/middleware/*.js |
| Reporters | html, json, json-summary, lcov, text-summary, cobertura |
| Output | .coverage/, plus .testresults/test-results.trx |
| Transform | @swc/jest (config) — babel-jest in practice |
Two kinds of test
Unit — *.test.js beside each helper
Most helpers are pure functions of their arguments, which is precisely why the codebase is split into so many tiny modules. Each has a focused test file.
Integration — prerender.integration.test.js
Builds a real Express app with cookie-parser, mounts the middleware, and drives it with
supertest. This is where cache hits, misses and header behaviour are verified end to end.
Files with no test file
- getSessionCookie
- isCurrentBuild
- handleInvalidPrerender
- forwardToPrerenderServer
- MockRedis
- prerenderStaticPage (has one)
forwardToPrerenderServer and handleInvalidPrerender are the notable gaps — both are on
the critical path and both are exercised only indirectly.
jest.setup.js installs process.on('unhandledRejection') and
'unhandledException' handlers that rethrow. Since the library fires several promises without
awaiting them, a test that leaves one rejecting will fail loudly rather than silently.
CI/CD & Release
Azure DevOps pipelines under cicd/, using shared template repositories.
| File | Trigger | What it does |
|---|---|---|
cicd/build.yaml |
main, release/* |
The entry pipeline. Extends build.yaml@yaml-pipeline-templates, injects
build-legacy.yaml as its build steps, and also builds for
release/, feature/ and PR refs. Runs on the build-agents-node pool.
|
cicd/build-legacy.yaml |
Included by the above |
Checks out with persisted credentials, sets up Node 22.14.0, runs yarn build, then
npm publish from ./lib.
|
cicd/merge-validator-dev.yaml |
PR validation (trigger: none) |
Node setup then yarn test. This is the quality gate on a pull request. |
cicd/renovate.yaml |
Cron 0 3 * * 0 — Sundays 03:00 |
Runs the shared Renovate template against renovate.config.js using the
github-secret variable group.
|
Versioning
The README describes the intended flow: raise a PR to main whose title contains
[major], [minor] or [patch], and a new package version is released
automatically.
In cicd/build-legacy.yaml the entire "VERSION BUMP STAGE" — reading the semver keyword from the
merge commit, running npm version, committing and pushing the tag — is commented out. As checked
in, the pipeline builds and publishes whatever version is already in package.json. Confirm with
the owning team how versions are bumped today before relying on the README's description.
Dependency automation
renovate.jsonextends@vfuk/digital-foundations, with Vodafone commit-message conventions (chore: 🔧,chore:❗️[major]).- Runtime and peer dependency updates wait a
minimumReleaseAgeof 3 days. - Minor and patch dev-dependency updates automerge;
[skip ci]is appended for devDependencies. renovate.config.jsis the config file the pipeline points at.
Quality gates
- Tests — merge validator pipeline.
- Coverage — 90% thresholds in the jest config.
- SonarQube — configured via
sonar-project.properties, but that file is still the unmodified template and points at a non-existent./src/package, so its analysis will not reflect this repo. - Review —
.github/CODEOWNERS, ported from the previous Azure DevOps branch policies.
Repo Tooling
| File | Purpose | Notes |
|---|---|---|
.linkrc.js |
Local development linking into a consuming app |
compileYarnCmd: 'build', output lib flattened on link, source and target
cleanup enabled, whitelist ['web-shop-'], copies package.json, nodemon watches
./src.
|
.nvmrc / volta |
Node version pinning | Volta pins Node 18.20.5 and Yarn 1.22.21; CI uses Node 22.14.0. |
.npmrc |
Private registry configuration | Copied into lib/ by postbuild so publishing targets the internal registry. |
.npmignore |
Publish exclusions | Also copied into lib/. |
.prettierrc.json / .prettierignore |
Formatting | Applied on save / pre-commit in the usual Vodafone setup. |
package.json → standard |
Linting config | Declares jest globals and uses babel-eslint as the parser. |
src/tools/jest/jestTrxProcessor.js |
Test reporting | Writes .testresults/test-results.trx for Azure DevOps test tabs. |
CHANGELOG.md |
Release history | Currently empty despite being linked from the README. |
.github/CODEOWNERS |
Review ownership | Generated from the old ADO branch policies during the GitHub migration. |
Recent commits show a move from Azure DevOps to GitHub (Renovate config migration, branch reference updates,
CODEOWNERS import, removal of catalog-info.yaml in favour of a central catalog). Expect some
config files to reference both worlds for a while.
Gotchas & Sharp Edges
Real observations from the current code. None of these are hypothetical — each one is something a new contributor will otherwise discover the hard way.
The default buildHash breaks multi-instance cachingHigh impact
uuidv4() runs once per Node process. Behind a load balancer each instance has a different
hash, so every instance treats the others' cache entries as "old build" and regenerates them. Effective
cache hit rate collapses to near zero, silently — no errors, just slow pages.
Fix: always pass a deployment-stable buildHash (webpack bundle hash, image
tag, release SHA).
isCurrentBuild passes for an empty hashSilent
html.includes('') is always true. If buildHash resolves to an empty
string, the build check passes for everything and release invalidation stops working — while every log line
looks healthy.
A stuck isCurrentlyPrerendering flag disables a URL for up to 24 hoursHigh impact
The flag has no timestamp of its own and no timeout. If the process dies between setting the flag and
writing the result, or if handleInvalidPrerender itself throws (it has no
try/catch and JSON.parse(null) yields null), the flag stays set.
Every later request sees "someone is already rendering this" and falls through to CSR until the Redis TTL
expires.
Symptom: one URL stops caching and never recovers while others are fine.
Workaround: delete that cache key in Redis.
options.buildHash is mutated on the shared objectConcurrency
if (options.getBuildHash) options.buildHash = await options.getBuildHash(req) writes to the
closed-over options object, not a per-request copy. Harmless when getBuildHash is constant,
but if it varies per request, concurrent requests can read each other's value.
Reordering queryParamsToRetain invalidates the whole cacheOperational
Cache-key fragments follow the array order, not the URL order or alphabetical order. Swapping two entries produces different keys for identical pages, so every entry becomes a cold miss on deploy.
alwaysPrerenderRoutes matches the full URL exactlyEasy to get wrong
It is Array.includes(req.url), so the query string is part of the comparison and there is no
pattern matching. '/plans' will not match /plans?segment=business or
/plans/.
Placeholder replacement is first-occurrence onlyTemplate bug
Only the prerenderServerUrl → cdnDomain rule uses a global RegExp. If a marker
such as var _hydratedData = {}; appears twice in your template, the second one survives and
overwrites the injected data at runtime.
A missing cookie-parser throwsSetup
initShouldSkipPrerender reads req.cookies.basketId directly. Without
cookie-parser mounted first, req.cookies is undefined and the
property access throws.
A rejected requiredForPrerender permanently blocks cachingClient-side
The decrement happens after await dataCall(). If the call rejects, the catch path rejects the
promise without decrementing, so outstandingRequiredRequests stays above zero and
getIsCacheable rejects every render of that page. hydrate with
isRequired: true has the same shape.
rejectUnauthorized: false on every prerender requestSecurity posture
TLS certificate verification is disabled for the call to the prerender service, and a fresh
HttpsAgent is created per request — so the keep-alive pooling that
agentkeepalive exists to provide is not actually reused. Acceptable for an internal host with
a self-signed certificate, but worth being explicit about.
Fire-and-forget calls hide failuresObservability
forwardToPrerenderServer is never awaited. Its own try/catch catches everything
and logs a console.warn. There is no metric, no error propagation and no retry beyond the
next request — so a prerender service outage looks like "the site is a bit slower" unless someone is
reading logs.
Hard-coded TTLs and a hard-coded localhost portConfig debt
86400 appears in forwardToPrerenderServer and
cachePageResponse; 259200 in forwardToPrerenderServer;
http://localhost:8000 in buildPrerenderUrl. None are options, so a shop running
its dev server on a different port cannot prerender locally without the boilerplate env vars.
Naming and housekeeping inconsistenciesCosmetic
- Folder
fixPrerenderServerUrl/containsfixPrerenderServiceUrl.js. - Folder
replacePlaceHolders/containsreplacePlaceholders.js. returnCachedPage.jsstarts with a stray;;.prerender.jsimportsreturnStaticCachedPageby full file path while every other import uses the folder.CHANGELOG.mdis empty;sonar-project.propertiesis the untouched template.- Several npm scripts point at directories that do not exist.
followRedirectin the axios options is not an axios option (it would bemaxRedirects).
Troubleshooting
Every log line from this library is prefixed [Prerender]. Grep for that first.
Log lines and what they mean
| Log | Level | Meaning |
|---|---|---|
| New route not in cache. Requesting prerender for… | info | Normal cold miss. Expected on first visit. |
| request starting for… | info | A render has been requested. |
| Updated bot cache for… / Updated cache for… | info | Success. Both caches written. |
| Cache expired for… Requesting prerender | info | Normal background refresh. The stale page was still served. |
| Cached page is old build… | warn | Expected once after a deploy. Persistent = unstable buildHash. |
| Initial prerender for URL … failed. Retrying… | warn | A placeholder was found with no result. Retry fired. |
| Request to prerender failed with status code… | warn | The render service returned a non-cacheable status. |
| Required requests unsuccessful during prerender… | warn | outstandingRequiredRequests was non-zero — the page was incomplete. |
| BuildHash not found in server render | warn | The hash is not in the HTML. Check the template and the value. |
| Request to prerender failed | warn | Network error or timeout calling the render service. |
| Failed to return cached page… | warn | The cached entry could not be read or parsed. |
| Failed to set cached page for… | warn | Redis write failed. Response was unaffected. |
| Failed to remove script tags for static render. | error | The regex strip threw. The unmodified HTML was used. |
Symptom → likely cause
Nothing is ever cachedCommon
- Is
X-Prerender-Cacheever present on a response? If never, the middleware may be skipping. - Is
prerenderServerUrlset? A falsy value disables the library entirely and logs nothing. - Is
prerender()mounted before your renderer, and aftercookieParser()? - Do you have a
basketId,JourneyIDor session cookie in your browser? Try a private window. - Look for BuildHash not found or Required requests unsuccessful — those are outright rejections.
One specific URL never cachesCommon
Most likely a stuck isCurrentlyPrerendering flag. Inspect the key in Redis; if it holds a
marker with no statusCode, delete it. Second possibility: a required API call on that page
rejects, so the counter never returns to zero.
Cache hit rate is poor in production but fine locallyCommon
Almost always the buildHash. Locally there is one process, so the uuid default is consistent;
in production every instance disagrees. Look for a steady stream of Cached page is old build
warnings — that is the fingerprint.
The page renders, then visibly re-fetches everythingClient-side
The hydration data did not make it in. Check that var _hydratedData = {}; appears in the
template exactly once and byte-for-byte, that your API calls are wrapped in hydrate() with
stable keys, and — if you are on the new boilerplate — that PRERENDER_BOILERPLATE=true is
actually set to the string 'true'.
Assets 404 on a cached pageConfig
The CDN rewrite. Outside NODE_ENV=local, every occurrence of
prerenderServerUrl is replaced with cdnDomain — or with an empty string if
cdnDomain is unset, which can mangle asset URLs. Set cdnDomain explicitly.
Googlebot sees an empty pageSEO
The bot cache is only ever written during a user-driven prerender, never by the bot branch itself.
A URL that only crawlers visit will have no static entry. Add it to
alwaysPrerenderRoutes or drive traffic to warm it, and verify the content survives
removeScriptTags — anything rendered by client-side JavaScript will be gone.
Users report seeing stale pricesBy design
Expected within the stale-while-revalidate window: an expired page is served once while its replacement is
built. Lower cacheSeconds to shrink the window. If staleness lasts far longer, the background
refresh is failing — check the warn-level logs.
Infinite loop / the app calls itself repeatedlySerious
The x-prerender header is not reaching the middleware. That header is what makes the
middleware recognise the render service's own request and skip itself. Check that nothing between the
render service and the app strips it.
curl -sI https://<host>/<path> | grep -i x-prerender-cache. Present means you were
served from cache. Absent means the middleware skipped, missed, or deliberately deferred — and the
[Prerender] logs will say which.
Glossary
Prerender
Building a page's finished HTML ahead of time, on a server, instead of in the visitor's browser.
Prerender service
A separate deployment running a headless browser. Accepts POST /render with a URL and returns the finished HTML plus captured data. Not part of this repo.
Client-side rendering (CSR)
The normal path: the browser downloads JavaScript, calls APIs, and draws the page itself. The fallback whenever prerender is skipped or misses.
Hydration
Giving the browser the API data the prerender already fetched, so the interactive app boots without repeating those calls.
Middleware
A function Express runs on each request. It either responds or calls next() to pass the request on.
Cache key / cacheable URL
The string identifying one cached page variant: environment prefix + path + declared query params and cookies.
Bot cache / static page
The static- prefixed variant with scripts stripped and JSON-LD kept, served to Googlebot and kept for 72 hours.
Stale-while-revalidate
Serve the expired copy immediately, build the replacement in the background. Nobody waits; content is briefly slightly old.
Single flight
Ensuring only one render is in progress per key at a time — here via the isCurrentlyPrerendering marker.
Build hash
An identifier for the current release, stamped into cached pages so a deploy automatically invalidates them.
TTL
Time to live — how long Redis physically keeps a key. Distinct from cacheSeconds, which governs logical freshness.
IDM / assurance level
Vodafone's identity system and its measure of how strongly a visitor is authenticated. Level 3 or above means logged in — never serve a shared page.
Journey
An in-progress purchase or upgrade flow, evidenced by a basketId, JourneyID or transfer transaction cookie.
Tealium / utag
Vodafone's tag-management platform. utag.sync.js must load synchronously in <head>, which is why it is injected by placeholder replacement.
JSON-LD
Structured data embedded in a <script type="application/ld+json"> tag. Preserved in bot pages because search engines build rich results from it.
x-prerender header
Set on requests originating from the prerender service. Its presence makes the middleware skip itself, breaking the render loop.
Boilerplate
The newer Vodafone app scaffold. PRERENDER_BOILERPLATE=true switches the library to its marker and endpoint conventions during migration.
outstandingRequiredRequests
A counter on window._hydratedData. Must be 0 when the snapshot is taken, otherwise the render is judged incomplete and is not cached.