angular-isr
v0.2.0
Published
Incremental Static Regeneration (ISR) for Angular SSR applications with multitenancy, CMS webhooks, and hybrid rendering
Maintainers
Readme
angular-isr
Incremental Static Regeneration for Angular SSR.
An SSR app renders the same public page from scratch for every visitor. ISR renders it once, serves that HTML to everyone else, and re-renders it in the background once it gets old. Your render path doesn't change — it just stops running on every request.
It was built for multi-tenant sites, so tenant isolation lives in the cache key rather than being bolted on afterwards, and the cache and queue backends are meant to be replaced.
npm install angular-isrAngular 17+, @angular/ssr 17+, Node 18+. Express 4 or 5 for the bundled adapter.
The model
Every cached page is in one of these states. The engine's whole job is moving pages between them.
| State | Meaning | What the request gets |
|---|---|---|
| miss | not cached, or cacheVersion changed | blocking render, result cached |
| fresh | rendered less than ttl seconds ago | cached HTML |
| stale | past ttl, still inside staleTtl | cached HTML immediately, background re-render queued |
| revalidating | a background render is already running | cached HTML, no second job queued |
| error | adapter-only — the library never writes this state | served verbatim, age ignored, never revalidated |
request
│
▼
┌───────┐ render ┌───────┐ age > ttl ┌───────┐
│ miss │─────────▶│ fresh │─────────────▶│ stale │
└───────┘ └───────┘ └───┬───┘
▲ ▲ serve stale + │ enqueue job
│ │ ▼
│ │ ┌──────────────┐
│ └──────────────│ revalidating │
│ render succeeded └──────────────┘
│
└─── age > ttl + staleTtl (entry evicted)Two things worth knowing before you wire anything up.
One render per page at a time. Ten concurrent requests for an uncached page trigger one
render; the rest wait on it and read the result. That's RenderLock, and it's why a cold cache
under load doesn't take the process down.
Only page navigations go through ISR. The middleware passes everything else straight to the next handler:
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (STATIC_EXT_RE.test(req.path)) return next(); // .js .css .png .woff2 …
if (!req.accepts('html')) return next();So your API routes, static assets and POSTs are untouched. You can mount it broadly.
Setup
Three touchpoints: the Express server, the browser app config, and the server app config.
1. Server
import express from 'express';
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
import { MemoryCacheAdapter } from 'angular-isr/server';
import { createIsrEngine, createIsrMiddleware, createWebhookHandler } from 'angular-isr/adapters/express';
const app = express();
const angularApp = new AngularNodeAppEngine();
// The render function ISR wraps. Everything the engine does is "call this, or don't".
const angularHandler = createNodeRequestHandler(async (req, res, next) => {
const response = await angularApp.handle(req);
if (response) writeResponseToNodeResponse(response, res);
else next();
});
const isrEngine = createIsrEngine({
angularHandler,
cache: new MemoryCacheAdapter(),
cacheVersion: process.env['APP_VERSION'] ?? '1',
routes: [
{ path: '/blog/**', ttl: 60, staleTtl: 300, tags: ['blog'] },
{ path: '/', ttl: 3600 },
],
revalidation: { secret: process.env['ISR_SECRET']! },
onEvent: (e) => console.log('[ISR]', e.type, e.path, e.cacheState ?? '', `${e.durationMs ?? ''}ms`),
});
// Secret, rate limit and debounce window are inherited from the engine's `revalidation` config.
app.post(isrEngine.revalidationOptions.endpoint, express.json(),
createWebhookHandler({ engine: isrEngine }));
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
app.use(createIsrMiddleware({ engine: isrEngine, angularHandler }));Order matters between express.static and the ISR middleware — static first. The webhook is a
POST, so the middleware ignores it either way; put it wherever it reads best.
On Angular 17/18 with CommonEngine, angularHandler is the only thing that differs:
const commonEngine = new CommonEngine();
const angularHandler: RequestHandler = (req, res, next) => {
commonEngine
.render({
bootstrap: AppServerModule,
documentFilePath: join(serverDistFolder, 'index.server.html'),
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
})
.then((html) => res.send(html))
.catch(next);
};Use createIsrEngine(), not new IsrEngine(). The factory wires
revalidation.renderFnFactory for you using the same angularHandler. Construct the engine
directly and background revalidation is silently disabled — stale pages are served forever, and
your only clue is one error event saying renderFnFactory not configured. Reach for
new IsrEngine() when you need control over how background renders are built; see
Background rendering.
2. Browser — app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(withEventReplay()),
provideIsr(),
],
};3. Server app — app.config.server.ts
import { provideIsrServer } from 'angular-isr/server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(), provideIsrServer()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);provideIsrServer() swaps the ISR_FETCH token to a request-scoped fetch pulled out of
AsyncLocalStorage. That's how a service five levels down the injector tree gets the ISR-aware
fetch without anyone threading it through — the middleware runs your entire render inside
isrAsyncContext.run({ isrFetch }, …).
NgModule apps have no app.config.server.ts. Put provideIsrServer() in the providers
array of the module you hand to renderModule().
Routes
The engine only caches paths you list. Anything unmatched renders normally and is never written to the cache — which is what you want, or a crawler hitting garbage URLs fills your disk.
routes: [
{
path: '/blog/**',
ttl: 60, // seconds until the entry goes stale
staleTtl: 600, // seconds it's still served while re-rendering
tags: ['blog', 'content'], // for bulk invalidation
cacheHeaders: 'public, max-age=30, stale-while-revalidate=60',
},
{ path: '/about', tags: ['static'] }, // no ttl — never expires on its own
]Leaving ttl off means the page never expires. It stays fresh until something invalidates
it. That's the right setting for pages that only change when someone publishes, paired with a
webhook.
staleTtl defaults to Infinity, so by default a user never blocks on a re-render, no
matter how old the entry is. Set it when you'd rather make someone wait than serve HTML from
last week. Past ttl + staleTtl the entry is evicted and the next request is a blocking miss.
Globs are deliberately simple: * matches exactly one path segment, ** matches any run of
characters including slashes. A trailing /** also matches the bare parent, so /blog/** covers
/blog and /blog/anything/deep but not the sibling /blogger. First match in the array wins,
so order specific patterns before broad ones.
Declaring config on the route instead
If you'd rather keep ISR config next to the route it describes, withIsrConfig() produces a
route fragment and extractIsrRoutes() turns your route tree into the engine's routes array at
boot. Spread the fragment — don't assign it to data:
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent, ...withIsrConfig({ ttl: 3600 }) },
{ path: 'blog/:slug', component: BlogPostComponent, ...withIsrConfig({ ttl: 60, tags: ['blog'] }) },
];
// server.ts
const engine = createIsrEngine({ angularHandler, cache, routes: extractIsrRoutes(routes) });Parent and child segments are joined, :param becomes *, and output order is depth-first so
parents precede children — which matters, since the first match wins.
The fragment also registers a route-level provider, so components under that route can read their
own config with inject(ISR_ROUTE_CONFIG).
Lazy routes can't be walked. A route with loadChildren has its own config extracted but its
children skipped, and all skipped subtrees are reported in one warning at boot. Either call
extractIsrRoutes() on the lazy route file too and concatenate, or add an explicit entry to the
engine's routes. Pass onWarn to send those diagnostics to your own logger:
extractIsrRoutes(routes, { basePath: '/en', onWarn: (msg) => logger.warn(msg) });Server-side routes remains fully supported and is the better fit when you generate route lists
programmatically — a /en mirror built with .map(), for instance, is not something a route-tree
walk can reproduce.
Cache keys
tenantId : cacheVersion : pathThe path is normalized first — leading slash added, trailing slash removed, query string and fragment stripped.
That last part matters more than it looks. /products?page=2 and /products are the same cache
entry, so whichever renders first wins for everybody. If a page's server render reads query
params, either leave it out of routes or make the SSR output query-independent and apply
filters client-side after hydration. This is the most common way to ship a subtly broken ISR
setup.
Override the scheme with cacheKeyResolver when you need to — it receives the raw request:
cacheKeyResolver: (req, tenantId, version, path) =>
`${tenantId}:${version}:${(req as Request).headers['accept-language']}:${path}`,Multitenancy
tenantResolver runs per request and its result becomes the first segment of the key. Pages,
tags and invalidation are then isolated by construction — clearing tenant A's blog tag cannot
touch tenant B.
tenantResolver: (req) => (req as Request).hostname.split('.')[0], // subdomain
tenantResolver: (req) => (req as Request).path.split('/')[1] ?? '', // path prefix
tenantResolver: (req) => (req as Request).headers['x-tenant-id'] as string ?? 'default',Return a real sentinel rather than an empty string for "no tenant" ('__notenant__', say) — an
empty segment is easy to collide with later.
The webhook debouncer is per-tenant: webhooks for two different tenants
inside one debounceMs window flush separately, each scoped to its own tenant.
'__all__' only appears in a flush when a caller sends it explicitly — the
debouncer no longer merges distinct tenants into a wildcard. Lower debounceMs
to batch less, raise it to batch more; tenant isolation is preserved either way.
Running more than one instance
MemoryCacheAdapter is for one process. The moment you scale past a single instance you need a
shared backend (Redis, filesystem) — and a shared CacheAdapter plus a shared
RevalidationQueueAdapter are the whole story for the cache and for invalidation. They are not
the whole story for the render path: four structures stay per-process even with shared
backends.
| Structure | Scope | What Redis does not fix |
|---|---|---|
| CacheAdapter | shared if you provide one | — (shared cache works) |
| RevalidationQueueAdapter | shared if you provide one | — (shared queue works) |
| RenderLock | per-process | A cold key costs one render per instance — every instance that sees the miss renders it, because the lock only serializes within a process. |
| directRevalidations (the lease-takeover guard) | per-process | A lease takeover may run once per instance for the same abandoned job. |
In practice: the cache and the invalidations are correct under Redis, but a cold key still causes one redundant render per instance, and a crashed background render is taken over once per instance. Both are bounded — they happen at most once per cold key, not per request — and both are the reason the render lock and the takeover guard exist. A real cross-process lock would remove them; that is issue 19 in the hardening plan, and intentionally not built speculatively.
Invalidation
Three ways in, and you'll use all three.
Webhook, for content changes:
curl -X POST https://example.com/_isr/revalidate \
-H "Content-Type: application/json" \
-H "X-ISR-Secret: $ISR_SECRET" \
-d '{ "paths": ["/blog/my-post"], "tags": ["blog"], "tenant": "acme" }'Everything in the body is optional. Paths and tags both scope to tenant; omit all three and
that tenant is cleared wholesale. The handler answers 202 immediately and does the work after
the debounce window, so don't assert on the cache in the next line of a test — poll.
The endpoint is hardened by default: timing-safe secret comparison (401 on mismatch), 60
requests per minute per client (429), optional X-Idempotency-Key deduplication over a 24-hour
window, and a 500ms debounce that collapses a bulk publish into one invalidation.
Set trust proxy if you're behind a load balancer. Callers are bucketed by req.ip, which
Express derives from x-forwarded-for only as far as that setting allows. Reading the header
unconditionally would let anyone reset their own rate-limit bucket by varying a header they
control — and the limiter is the only thing bounding attempts to guess your secret. The
consequence of leaving it unset is the safe one: every request from the proxy shares a bucket.
When the handler sees a request with x-forwarded-for set while req.ip still equals the socket
address, it emits one warning at boot (through console.warn and onEvent) so a misconfigured
proxy deployment is no longer silent.
app.set('trust proxy', 1); // one proxy hop in front
// or bucket by something else entirely:
createWebhookHandler({ engine, clientIdentifier: (req) => tenantOf(req) });req.ip is undefined on non-Express request objects — a synthetic driver, a fastify
adapter hand-rolled against the handler, anything that didn't come through Express. In that case
the limiter falls back to req.socket.remoteAddress. If the synthetic request carries no socket
either, the bucket is the literal 'unknown', so every such request shares one bucket. Pass
clientIdentifier when your request shape is not Express's.
Programmatic, anywhere you have the engine:
await isrEngine.invalidate({ tenantId: 'acme', paths: ['/pricing'], tags: ['blog'] });
await isrEngine.invalidate({ tenantId: '__all__', tags: ['blog'] }); // every tenantOn deploy, by moving cacheVersion:
cacheVersion: process.env['APP_VERSION'] ?? '1',Entries carry the version they were rendered under, and a mismatch reads as a miss. No flush step in your pipeline; old entries just become unreachable and age out.
CMS webhooks
The bundled adapters each verify their CMS's own auth scheme and normalize the payload to
{ paths, tags, tenant }. What you do with that is up to you, which is why they aren't wired
into /_isr/revalidate automatically.
| Adapter | Verifies | Tag mapping |
|---|---|---|
| ContentfulIsrAdapter | x-contentful-webhook-secret | contentTypeTagMap |
| SanityIsrAdapter | HMAC-SHA256 sanity-webhook-signature | documentTypeTagMap |
| StrapiIsrAdapter | Authorization: Bearer | modelTagMap |
const contentful = new ContentfulIsrAdapter({
secret: process.env['CONTENTFUL_WEBHOOK_SECRET']!,
contentTypeTagMap: { blogPost: ['blog', 'content'], author: ['authors'] },
pathResolver: (fields) => {
const slug = fields['slug']?.['en-US'] as string | undefined;
return slug ? `/blog/${slug}` : undefined;
},
});
app.post('/webhooks/contentful', express.json(), async (req, res) => {
try {
const { paths, tags } = await contentful.parseWebhook(req); // throws on a bad signature
await isrEngine.invalidate({ tenantId: '', paths, tags });
res.json({ ok: true });
} catch {
res.status(401).json({ error: 'invalid signature' });
}
});Any other CMS is a class with one method — implement CmsAdapter and the rest is identical.
Cache adapters
MemoryCacheAdapter is for one process. Run two instances behind a load balancer and you have
two independent caches: a webhook clears one, and the other keeps serving old HTML until its TTL
runs out. There's no clever way around this. The moment you scale past a single instance you
need a shared backend.
The interface is small.
interface CacheAdapter {
get(key): Promise<CacheEntry | null>;
set(key, entry): Promise<void>;
delete(key): Promise<void>;
deleteByTag(tenantId, tag): Promise<string[]>;
deleteByTenant(tenantId): Promise<string[]>;
deleteByPath?(tenantId, path): Promise<string[]>; // optional
setState?(key, state): Promise<void>; // optional
}Three rules that aren't visible in the signatures, and that decide whether your adapter works:
- Derive the state from
createdAton every read. The persistedstatefield is written asfreshat render time and is not authoritative afterwards. Return it as-is and TTL never fires: pages stay "fresh" forever and nothing revalidates. CompareDate.now() - createdAtagainstttlandttl + staleTtl, and evict past both. - Serve
errorandrevalidatingverbatim. Those are lifecycle states, not ages. Applying TTL logic to them breaks background revalidation. - Implement
deleteByPathfor reliable path invalidation. It's optional, but without itinvalidate({ paths })falls back to building the cache key — and a customcacheKeyResolverthat reads anything offreqthen builds a key that doesn't match the one used at write time, so the entry survives. WithdeleteByPath, path invalidation is a reverse lookup on(tenantId, path)and is request-independent. It's also required forinvalidate({ tenantId: '__all__', paths }): without it the engine emits anerrorevent per path and moves on. Tag-based invalidation still works without it.setStateis a pure optimization — the engine falls back to read-modify-write.tenantId === '__all__'is a wildcard in the delete methods, not a literal tenant. Handle it.
A Redis adapter is about forty lines:
export class RedisCacheAdapter implements CacheAdapter {
private client = createClient({ url: process.env['REDIS_URL'] });
async get(key: string): Promise<CacheEntry | null> {
const raw = await this.client.get(key);
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (entry.state === 'error' || entry.state === 'revalidating') return entry;
if (entry.ttl === undefined) return { ...entry, state: 'fresh' };
const age = Date.now() - entry.createdAt;
if (age < entry.ttl * 1000) return { ...entry, state: 'fresh' };
if (age < (entry.ttl + (entry.staleTtl ?? Infinity)) * 1000) return { ...entry, state: 'stale' };
await this.delete(key);
return null;
}
async set(key: string, entry: CacheEntry): Promise<void> {
const seconds = entry.ttl ? entry.ttl + (entry.staleTtl ?? 0) : undefined;
await this.client.set(key, JSON.stringify(entry), seconds ? { EX: seconds } : undefined);
}
// delete / deleteByTag / deleteByTenant / deleteByPath: scan the `${tenantId}:*` keyspace
}Writing to the filesystem instead? Write a temp file and rename() it. A torn read must never
serve half a page.
Background rendering
When a page goes stale the engine has to render it with no incoming request to work from.
createIsrEngine() handles that by building a synthetic request against localhost.
That's fine for a single-tenant site and wrong for a multi-tenant one: localhost names no
tenant, so every background render produces the generic page and writes it under a real tenant's
key. Nothing errors — you find out when a customer sees someone else's homepage. If your render
depends on the host, supply your own factory and construct the engine directly:
const isrEngine = new IsrEngine({
cache, cacheVersion, tenantResolver, routes,
revalidation: {
secret: process.env['ISR_SECRET']!,
renderFnFactory: (tenantId, path) => (isrFetch) => {
const host = hostForTenant(tenantId);
if (!host) return Promise.reject(new Error(`no host for "${tenantId}" — skipping`));
// build a request with that host, run your real handler inside
// isrAsyncContext.run({ isrFetch }, …), resolve with the HTML
},
},
});Two rules for a custom factory. Reject the job rather than guess when you can't reconstruct the request faithfully — the stale entry keeps being served, which is strictly better than caching the wrong page. And throw on a 5xx render, so a failing backend never overwrites good HTML with an error page.
Revalidation queue and retries
The default queue is in-process: it deduplicates by cache key, retries with exponential backoff,
and hands terminal failures to deadLetterLog.
revalidation: {
secret: process.env['ISR_SECRET']!,
retryPolicy: { maxAttempts: 5, backoffMs: 2000 }, // 2s, 4s, 8s, 16s
deadLetterLog: (job, error) =>
sentry.captureException(error, { extra: { path: job.path, tenant: job.tenantId } }),
}Point deadLetterLog at something that pages you. A background render that has failed five
times means a page is pinned to old content and nothing else will tell you.
Swap the queue when revalidation needs to survive a restart or span instances:
export class BullMqQueueAdapter implements RevalidationQueueAdapter {
private queue = new Queue('isr-revalidation');
onProcess(handler: (job: RevalidationJob) => Promise<void>) {
new Worker('isr-revalidation', (job) => handler(job.data));
}
async enqueue(job: Omit<RevalidationJob, 'attempt' | 'enqueuedAt'>) {
await this.queue.add('revalidate', job, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
}
}Takeover strategy
When a background render is abandoned (the lease expires), the engine takes the
lease over and re-dispatches the render. How that one recovery render is
dispatched is revalidation.takeoverStrategy:
revalidation: {
takeoverStrategy: 'auto', // default
// 'auto' — through the queue when it implements forget(), else run directly.
// 'queue' — always through the queue, even on takeover, even without forget().
// Only if you know your queue does not dedupe on cacheKey — a deduping
// queue without forget() will silently drop the takeover.
// 'direct' — never through the queue on takeover; always run the recovery render
// directly, giving up the queue's retry/backoff/durability for that one
// render but guaranteeing it runs.
}'auto' is the safe default: it keeps full queue semantics whenever the queue
implements forget() (so the takeover isn't deduplicated away) and falls back to
a direct render when it doesn't. Choose 'queue' when you know your queue
doesn't dedupe, or 'direct' when you'd rather always pay one out-of-band
render than trust the queue's cooperation on a takeover. The built-in
MemoryQueueAdapter implements forget().
Data fetching
ISR_FETCH is a fetch that works on both platforms: native fetch in the browser, and during
SSR the request-scoped instance the engine created. Use it to declare which of your data is
cacheable.
@Injectable({ providedIn: 'root' })
export class BlogService {
private fetch = inject(ISR_FETCH);
getPosts() {
return this.fetch('/api/posts', { isr: { cache: true, ttl: 300, tags: ['blog'] } });
}
getCart() {
return this.fetch('/api/cart', { isr: { cache: false } }); // per-user, never recorded
}
}cache: true (the default) records the response against the render; cache: false excludes it.
Declared tags and ttl are folded into the cache entry automatically:
- entry tags = route tags ∪ tags declared by every cached fetch, so a
blogtag declared on a data fetch reaches the page even when the route carries no such tag —invalidate({ tags: ['blog'] })then hits the page that rendered from that data. Tags are always unioned: widening invalidation is safe. - entry TTL =
min(route ttl, ...fetch ttls)— but only when the route has attl. A route that deliberately omitsttlis documented as "never expires on its own, paired with a webhook" — a route-level decision owned by whoever controls the page. A transitive service dependency declaringisr: { ttl: 300 }on a fetch does not override that decision, because the person who added the fetch TTL has no idea which routes consume their service. TTL narrowing is gated on the route opting into time-based expiry at all; tags are not, because they widen invalidation rather than shorten lifetime. cache: falsefetches contribute neither — they are never recorded, so their tags and TTL stay out of the entry.
Fetched data itself is not transferred to the client, so the browser
refetches after hydration; isr.fetch is a cache-control declaration, not a
transfer-state hook.
Cache metadata in a component
@Component({ /* … */ })
export class BlogPostComponent {
protected isr = inject(IsrService);
// isr.tenant() isr.ttl() isr.tags() isr.cacheState()
}Signals, read once at construction from the #ng-isr-state tag the engine injects into the
response. Because that tag is written per response rather than baked in at render time,
cacheState() reflects how this request was served — 'fresh', 'stale' or 'revalidating'.
ttl() and tags() describe the cache entry serving this response, not your current route
config. That is the useful answer: the entry's TTL is what the cache actually derives
fresh/stale from, and its tags are what deleteByTag keys on — so a page whose data was
fetched with isr: { ttl: 60, tags: ['blog'] } reports 60 and ['blog'] even when its route
says ttl: 3600. The consequence to know about: after a deploy that changes a route's ttl or
tags, entries rendered under the old config keep reporting the old values until they are
re-rendered. Bump cacheVersion if you need the change to take effect immediately.
On the server every getter returns its default; there is nothing to read until the page reaches a browser.
Events
onEvent is the only window into what the cache is doing. Wire it up before you need it.
onEvent: (event) => {
metrics.increment(`isr.${event.type}`, { tenant: event.tenantId });
if (event.type === 'error') logger.error(event.error, { path: event.path });
}Types are hit, miss, revalidate, error and webhook, each with tenantId and path,
plus cacheState, durationMs, error and meta where they apply. Your hit rate is a review
of your TTLs: if it's low, they're too short.
CDN headers
cacheHeaders sets Cache-Control on every response for that route, including the first
render. ISR and a CDN stack well — the CDN absorbs the repeat hits, ISR absorbs the misses.
{ path: '/blog/**', ttl: 60, cacheHeaders: 'public, max-age=30, stale-while-revalidate=60, stale-if-error=86400' }Entry points
| Import | Contains | Safe in a component |
|---|---|---|
| angular-isr | provideIsr, IsrService, ISR_FETCH, withIsrConfig, extractIsrRoutes | yes |
| angular-isr/server | IsrEngine, provideIsrServer, cache/queue/CMS adapters | no — Node only |
| angular-isr/adapters/express | createIsrEngine, createIsrMiddleware, createWebhookHandler | no — Express only |
The split is load-bearing. angular-isr/server imports node:async_hooks and node:crypto;
importing it from a component either ships Node polyfills to the browser or fails the build.
Known limits
Accurate as of 0.2.0. Each of these is tracked in docs/hardening/plan.md.
extractIsrRoutes()cannot see inside lazy routes.loadChildrensubtrees are skipped (reported in one boot warning); configure them server-side or extract their route file separately. See Declaring config on the route instead.
API
angular-isr — provideIsr(config?), IsrService (cacheState, ttl, tenant, tags
signals), ISR_FETCH, withIsrConfig, extractIsrRoutes, ISR_ROUTE_CONFIG.
angular-isr/server — IsrEngine (handle, invalidate, matchRoute), provideIsrServer,
MemoryCacheAdapter, MemoryQueueAdapter, ContentfulIsrAdapter, SanityIsrAdapter,
StrapiIsrAdapter, verifySecret, verifyHmacSha256, and the CacheAdapter,
RevalidationQueueAdapter and CmsAdapter interfaces.
angular-isr/adapters/express — createIsrEngine, createIsrMiddleware,
createWebhookHandler.
License
MIT
