npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

angular-isr

v0.2.0

Published

Incremental Static Regeneration (ISR) for Angular SSR applications with multitenancy, CMS webhooks, and hybrid rendering

Readme

angular-isr

Incremental Static Regeneration for Angular SSR.

npm license

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-isr

Angular 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 : path

The 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 tenant

On 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:

  1. Derive the state from createdAt on every read. The persisted state field is written as fresh at render time and is not authoritative afterwards. Return it as-is and TTL never fires: pages stay "fresh" forever and nothing revalidates. Compare Date.now() - createdAt against ttl and ttl + staleTtl, and evict past both.
  2. Serve error and revalidating verbatim. Those are lifecycle states, not ages. Applying TTL logic to them breaks background revalidation.
  3. Implement deleteByPath for reliable path invalidation. It's optional, but without it invalidate({ paths }) falls back to building the cache key — and a custom cacheKeyResolver that reads anything off req then builds a key that doesn't match the one used at write time, so the entry survives. With deleteByPath, path invalidation is a reverse lookup on (tenantId, path) and is request-independent. It's also required for invalidate({ tenantId: '__all__', paths }): without it the engine emits an error event per path and moves on. Tag-based invalidation still works without it. setState is 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 blog tag 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 a ttl. A route that deliberately omits ttl is 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 declaring isr: { 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: false fetches 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. loadChildren subtrees 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-isrprovideIsr(config?), IsrService (cacheState, ttl, tenant, tags signals), ISR_FETCH, withIsrConfig, extractIsrRoutes, ISR_ROUTE_CONFIG.

angular-isr/serverIsrEngine (handle, invalidate, matchRoute), provideIsrServer, MemoryCacheAdapter, MemoryQueueAdapter, ContentfulIsrAdapter, SanityIsrAdapter, StrapiIsrAdapter, verifySecret, verifyHmacSha256, and the CacheAdapter, RevalidationQueueAdapter and CmsAdapter interfaces.

angular-isr/adapters/expresscreateIsrEngine, createIsrMiddleware, createWebhookHandler.

License

MIT