diff --git a/.gitignore b/.gitignore index 03ada4a200..9b9bbba2fa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !.yarn/releases !.yarn/sdks !.yarn/versions +llm-wiki/ *.pfx *.publishsettings diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index 9135c05a88..a840ee32a6 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -10,6 +10,7 @@ "@angular/common", "@angular/core", "@angular/router", + "unstorage", "@ngx-translate/core" ] } diff --git a/packages/angular/package.json b/packages/angular/package.json index be665a3dd5..0ed55ca5f9 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -65,7 +65,8 @@ "@ngx-translate/core": "^17.0.0", "@sitecore-content-sdk/content": "^2.1.0", "@sitecore-content-sdk/core": "^2.1.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "unstorage": "^1.17.5" }, "devDependencies": { "@angular/build": "^21.1.4", diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index 7212b528f7..a8c8451086 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -32,6 +32,16 @@ export interface AngularSitecoreConfigInput extends Omit angular: { /** Resolved locales for the Angular app. Always contains at least `defaultLanguage`. */ locales: string[]; + /** + * Resolved configuration for the ISR-like cache. Defaults are applied by + * `defineConfig`: `enabled: true`, `revalidate: 300`. + */ + loadersCache: { + enabled: boolean; + revalidate: number; + }; }; } +/** Defaults applied to `angular.loadersCache` when input omits fields. */ +const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 } as const; + /** * Ensures `defaultLanguage` is present in the locales list (prepended when missing) and * returns an empty-input fallback of `[defaultLanguage]`. @@ -96,8 +117,13 @@ export function defineConfig( scConfig.redirects.locales = locales; + const loadersCache = { + enabled: angular?.loadersCache?.enabled ?? DEFAULT_ISR_CACHE.enabled, + revalidate: angular?.loadersCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate, + }; + return { ...scConfig, - angular: { locales }, + angular: { locales, loadersCache }, } as AngularSitecoreConfig; } diff --git a/packages/angular/src/lib/sitecore-context.service.spec.ts b/packages/angular/src/lib/sitecore-context.service.spec.ts index b14bce9fe4..51c0261261 100644 --- a/packages/angular/src/lib/sitecore-context.service.spec.ts +++ b/packages/angular/src/lib/sitecore-context.service.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable */ import { TestBed, ComponentFixture } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Component, PLATFORM_ID, REQUEST } from '@angular/core'; @@ -100,9 +100,7 @@ describe('SitecoreContextService', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: mockPage, dictionary: mockDictionary }) - ), + provideRouter(appLikeRoutes({ page: mockPage, dictionary: mockDictionary })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) }, SitecoreContextService, ], @@ -141,9 +139,7 @@ describe('SitecoreContextService', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: editingPage }) - ), + provideRouter(appLikeRoutes({ page: editingPage })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) }, SitecoreContextService, ], @@ -325,9 +321,7 @@ describe('SitecoreContextService effectiveLocale', () => { TestBed.configureTestingModule({ imports: [RouterHostCmp, BlankCmp], providers: [ - provideRouter( - appLikeRoutes({ page: makePage({ locale: 'fr' }) }) - ), + provideRouter(appLikeRoutes({ page: makePage({ locale: 'fr' }) })), { provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES], 'en'), diff --git a/packages/angular/src/loaders/loader-data.service.spec.ts b/packages/angular/src/loaders/client-loader-data.service.spec.ts similarity index 91% rename from packages/angular/src/loaders/loader-data.service.spec.ts rename to packages/angular/src/loaders/client-loader-data.service.spec.ts index 69944e89d6..366a8a5e27 100644 --- a/packages/angular/src/loaders/loader-data.service.spec.ts +++ b/packages/angular/src/loaders/client-loader-data.service.spec.ts @@ -4,13 +4,13 @@ import { PLATFORM_ID } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { LoaderDataService } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { FETCH_DATA_ENDPOINT } from './loader-registry.token'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import * as sdkCore from '@sitecore-content-sdk/core'; -describe('LoaderDataService', () => { - let service: LoaderDataService; +describe('ClientLoaderDataService', () => { + let service: ClientLoaderDataService; let httpController: HttpTestingController; let debugCommonSpy: ReturnType; @@ -23,7 +23,7 @@ describe('LoaderDataService', () => { const platformId = overrides.platformId ?? 'browser'; TestBed.configureTestingModule({ providers: [ - LoaderDataService, + ClientLoaderDataService, provideHttpClient(), provideHttpClientTesting(), { provide: PLATFORM_ID, useValue: platformId }, @@ -32,7 +32,7 @@ describe('LoaderDataService', () => { : []), ], }); - service = TestBed.inject(LoaderDataService); + service = TestBed.inject(ClientLoaderDataService); httpController = TestBed.inject(HttpTestingController); } @@ -46,7 +46,7 @@ describe('LoaderDataService', () => { }); describe('getData', () => { - it('should make new data request when no pending requests and data not in cache', async () => { + it('should make new data request when no pending requests and no staged prefetched response', async () => { setupTestBed(); const request = { url: '/test', loaderId: 'page' }; const resultPromise = service.getData(request); @@ -85,14 +85,14 @@ describe('LoaderDataService', () => { expect(result).toEqual({ kind: 'error', status: 500, - message: 'LoaderDataService only works in browser', + message: 'ClientLoaderDataService only works in browser', }); httpController.expectNone(LOADER_DATA_ENDPOINT); }); }); describe('prefetch', () => { - it('should populate cache without consuming so getData can read it without a new request', async () => { + it('should stage prefetched response without consuming so getData can read it without a new request', async () => { setupTestBed(); const request = { url: '/prefetched', loaderId: 'page' }; service.prefetch(request); @@ -111,12 +111,12 @@ describe('LoaderDataService', () => { httpController.expectNone(LOADER_DATA_ENDPOINT); }); - it('should not make a new request when cache is already populated', async () => { + it('should not make a new request when prefetched response is already staged', async () => { setupTestBed(); - const request = { url: '/cached', loaderId: 'page' }; + const request = { url: '/staged', loaderId: 'page' }; service.prefetch(request); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); - req.flush({ kind: 'data', data: { cached: true } }); + req.flush({ kind: 'data', data: { staged: true } }); await new Promise((r) => setTimeout(r, 0)); service.prefetch(request); diff --git a/packages/angular/src/loaders/loader-data.service.ts b/packages/angular/src/loaders/client-loader-data.service.ts similarity index 64% rename from packages/angular/src/loaders/loader-data.service.ts rename to packages/angular/src/loaders/client-loader-data.service.ts index d7c3aa523f..60ddab566a 100644 --- a/packages/angular/src/loaders/loader-data.service.ts +++ b/packages/angular/src/loaders/client-loader-data.service.ts @@ -3,17 +3,17 @@ import { isPlatformBrowser } from '@angular/common'; import { firstValueFrom } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { Params } from '@angular/router'; -import { LoaderApiRequest, LoaderApiResponse } from './models'; +import { LoaderApiRequest, LoaderApiResponse, LoaderCacheConfig } from './models'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import { FETCH_DATA_ENDPOINT } from './loader-registry.token'; /** - * Cache key generator for loader data. + * Staging key for prefetched loader responses (browser-only, consume-once). * @param {string} loaderId - Loader identifier * @param {string} url - Request URL - * @returns Cache key string + * @returns Staging key string */ -function cacheKey(loaderId: string, url: string): string { +function requestKey(loaderId: string, url: string): string { return `loader:${loaderId}:${url}`; } @@ -26,13 +26,25 @@ export interface LoaderDataRequest { loaderId: string; params?: Params; query?: Record; + /** + * Per-route cache overrides from `loaderResolver(id, cacheOptions)`. Sent + * to the server in the POST body so server-side cache policy matches the + * route's intent on CSR navigations. Phase 5 of the refactor plan. + */ + cacheOptions?: LoaderCacheConfig; } +/** + * Loader data client for browser loader data resolution. POSTs to the `/_data` endpoint and holds + * short-lived prefetched responses for parallel navigation prefetching. + * Not aware of the server-side {@link LoaderCache}. + * @public + */ @Injectable({ providedIn: 'root', }) -export class LoaderDataService { - private readonly cache = new Map(); +export class ClientLoaderDataService { + private readonly prefetchedResponses = new Map(); private readonly pending = new Map>(); private readonly http = inject(HttpClient); private readonly platformId = inject(PLATFORM_ID); @@ -40,51 +52,53 @@ export class LoaderDataService { inject(FETCH_DATA_ENDPOINT, { optional: true }) ?? LOADER_DATA_ENDPOINT; /** - * Prefetch loader data for the given request without consuming the cache. - * If data is already cached or a request is pending, does nothing. - * Otherwise starts a fetch and stores the result in cache for a later getData() call. - * Used by PreLoaderDataService to warm the cache for all loaders in a route in parallel. + * Prefetch loader data for the given request without consuming staged responses. + * If a response is already staged or a request is pending, does nothing. + * Otherwise starts a fetch and stores the result for a later getData() call. + * Used by PreLoaderDataService to warm responses for all loaders in a route in parallel. * @param {LoaderDataRequest} loaderRequest - The loader data request */ prefetch(loaderRequest: LoaderDataRequest): void { if (!isPlatformBrowser(this.platformId)) { return; } - const key = cacheKey(loaderRequest.loaderId, loaderRequest.url); - if (this.cache.has(key) || this.pending.has(key)) { + const key = requestKey(loaderRequest.loaderId, loaderRequest.url); + if (this.prefetchedResponses.has(key) || this.pending.has(key)) { return; } const promise = this.fetchData(loaderRequest); this.pending.set(key, promise); promise.then(() => { - // Result is already stored in cache by fetchData; nothing to consume + // Result is already stored in prefetchedResponses by fetchData }); } /** - * Get data for the given request, using cache or fetching if needed. + * Get data for the given request, using staged prefetched responses or fetching if needed. * If a request is already pending for this URL/loader combination, * waits for it to complete instead of making a duplicate request. - * Consumes (removes) cached data after retrieval. + * Consumes (removes) staged responses after retrieval. * @param {LoaderDataRequest} request - The loader data request * @returns {Promise} Promise resolving to the API response */ async getData(request: LoaderDataRequest): Promise { - // Only fetch in browser if (!isPlatformBrowser(this.platformId)) { - return { kind: 'error', status: 500, message: 'LoaderDataService only works in browser' }; + return { + kind: 'error', + status: 500, + message: 'ClientLoaderDataService only works in browser', + }; } - const key = cacheKey(request.loaderId, request.url); + const key = requestKey(request.loaderId, request.url); - // Return cached response if available (consume on use); supports data and redirect - const cached = this.cache.get(key); - if (cached !== undefined) { - this.cache.delete(key); - return cached; + const staged = this.prefetchedResponses.get(key); + if (staged !== undefined) { + this.prefetchedResponses.delete(key); + return staged; } - // Wait for pending request if one exists + // Wait for pending loader data request if one exists const pendingRequest = this.pending.get(key); if (pendingRequest) { return pendingRequest; @@ -104,15 +118,15 @@ export class LoaderDataService { * @returns {Promise} Promise resolving to the API response */ private async fetchData(request: LoaderDataRequest): Promise { - const key = cacheKey(request.loaderId, request.url); + const key = requestKey(request.loaderId, request.url); const endpoint = this.fetchDataEndpoint; const reqBody: LoaderApiRequest = { loaderId: request.loaderId, url: request.url, params: request.params ?? {}, query: request.query ?? {}, + cacheOptions: request.cacheOptions, }; - console.log('DEBUG: LoaderDataService fetchData', endpoint, reqBody); try { const resp = await firstValueFrom( @@ -120,18 +134,15 @@ export class LoaderDataService { ); if (!resp) { const message = `No response from ${endpoint}`; - console.log(`DEBUG: LoaderDataService fetchData: ${message}`); return { kind: 'error', status: 500, message } as LoaderApiResponse; } if (resp.kind === 'data') { - console.log('DEBUG: LoaderDataService fetchData: data', resp.data); - this.cache.set(key, resp); + this.prefetchedResponses.set(key, resp); } else if (resp.kind === 'redirect') { - this.cache.set(key, resp); + this.prefetchedResponses.set(key, resp); } return resp; } catch (error) { - console.log('DEBUG: LoaderDataService fetchData: error', error); const message = error instanceof Error ? error.message : 'Fetch failed'; return { kind: 'error', status: 500, message } as LoaderApiResponse; } finally { diff --git a/packages/angular/src/loaders/loader-registry.token.ts b/packages/angular/src/loaders/loader-registry.token.ts index 96ebf7a63c..4e465a5d22 100644 --- a/packages/angular/src/loaders/loader-registry.token.ts +++ b/packages/angular/src/loaders/loader-registry.token.ts @@ -12,16 +12,25 @@ export const FETCH_DATA_ENDPOINT = new InjectionToken 'FETCH_DATA_ENDPOINT' ); -export const LOADER_REGISTRY = new InjectionToken>('LOADER_REGISTRY'); +/** + * Cross-boundary loader registry — maps loader IDs to loader functions. + * The same registry is used for SSR, CSR (`/_data`), and route resolvers. + * There is no separate server vs client loader set. + * @public + */ +export type LoaderRegistry = Record; + +export const LOADER_REGISTRY = new InjectionToken('LOADER_REGISTRY'); /** - * Provides the loader registry for DI. Pass the loaders your app uses (e.g. page, '404', '500'). - * The same loader set must be registered on the server in createLoaderDataServiceMiddleware so - * client-side navigation can fetch route data via the data endpoint. - * @param {Record} loaders - Map of loader id to loader function + * Registers the app's loader registry for DI. Pass the loaders your app uses + * (e.g. page, '404', '500'). Use the **same object** with + * {@link createLoaderDataServiceMiddleware} in `server.ts` so SSR and CSR + * navigations resolve the same loader functions. + * @param {LoaderRegistry} loaders - Map of loader id to loader function * @public */ -export const provideLoaderRegistry = (loaders: Record): Provider[] => { +export const provideLoaderRegistry = (loaders: LoaderRegistry): Provider[] => { return [ { provide: LOADER_REGISTRY, diff --git a/packages/angular/src/loaders/loader-resolver.spec.ts b/packages/angular/src/loaders/loader-resolver.spec.ts index 14463317c2..5653efbb35 100644 --- a/packages/angular/src/loaders/loader-resolver.spec.ts +++ b/packages/angular/src/loaders/loader-resolver.spec.ts @@ -1,14 +1,16 @@ /* eslint-disable jsdoc/require-jsdoc */ import { TestBed } from '@angular/core/testing'; -import { PLATFORM_ID, REQUEST, TransferState, makeStateKey } from '@angular/core'; +import { PLATFORM_ID, REQUEST, TransferState, makeStateKey, REQUEST_CONTEXT } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { provideRouter, RedirectCommand, Router } from '@angular/router'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loaderResolver } from './loader-resolver'; import { LOADER_ID, LOADER_REGISTRY } from './loader-registry.token'; -import { LoaderDataService } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; +import { provideServerLoaderRunner } from '../server/provide-server-loader-runner'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; +import { createLoaderCache } from '../server/cache/loader-cache'; import type { LoaderFn } from './models'; import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; @@ -46,7 +48,7 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, - { provide: LoaderDataService, useValue: mockLoaderData }, + { provide: ClientLoaderDataService, useValue: mockLoaderData }, ], }); transferState = TestBed.inject(TransferState); @@ -72,7 +74,7 @@ describe('loaderResolver', () => { expect(mockLoaderData.getData).not.toHaveBeenCalled(); }); - it('should call LoaderDataService.getData with correct request and return data', async () => { + it('should call ClientLoaderDataService.getData with correct request and return data', async () => { mockLoaderData.getData.mockResolvedValue({ kind: 'data', data: { title: 'Home' } }); const resolver = loaderResolver('page'); @@ -157,7 +159,7 @@ describe('loaderResolver', () => { }); }); - describe('browser with real LoaderDataService (pending handling)', () => { + describe('browser with real ClientLoaderDataService (pending handling)', () => { let httpController: HttpTestingController; beforeEach(() => { @@ -168,7 +170,7 @@ describe('loaderResolver', () => { provideHttpClient(), provideHttpClientTesting(), TransferState, - LoaderDataService, + ClientLoaderDataService, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, ], @@ -231,7 +233,7 @@ describe('loaderResolver', () => { }); it('should remove pending promise when fetch settles so a later call triggers a new request', async () => { - const loaderData = TestBed.inject(LoaderDataService); + const loaderData = TestBed.inject(ClientLoaderDataService); const resolver = loaderResolver('page'); const route = makeRouteSnapshot({ pathFromRoot: [{ params: {} }] }); const state = makeRouterStateSnapshot('/after-settle'); @@ -275,7 +277,8 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderRunner(), ], }); transferState = TestBed.inject(TransferState); @@ -322,7 +325,7 @@ describe('loaderResolver', () => { expect(transferState.get(key, null)).toEqual({ server: true, title: 'SSR' }); }); - it('should throw when loader id is not in registry', async () => { + it('should throw LoaderHttpError when loader id is not in registry', async () => { const resolver = loaderResolver('missing' as 'page'); const route = makeRouteSnapshot(); const state = makeRouterStateSnapshot('/path'); @@ -333,7 +336,9 @@ describe('loaderResolver', () => { resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise )(route, state); }) - ).rejects.toThrow('No loader registered for id "missing"'); + ).rejects.toMatchObject({ + message: 'No loader registered for id "missing"', + }); }); it('should rethrow when loader throws', async () => { @@ -352,6 +357,42 @@ describe('loaderResolver', () => { }) ).rejects.toThrow('Loader failed'); }); + + it('should reuse cached loader output on SSR when REQUEST_CONTEXT provides a cache', async () => { + TestBed.resetTestingModule(); + const cachedLoader = vi.fn().mockResolvedValue({ cached: true }) as ReturnType & + LoaderFn; + const cache = createLoaderCache({ revalidate: 300 }); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + TransferState, + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: LOADER_REGISTRY, useValue: { page: cachedLoader } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + { provide: REQUEST_CONTEXT, useValue: { cache } }, + provideServerLoaderRunner(), + ], + }); + + const resolver = loaderResolver('page'); + const route = makeRouteSnapshot({ pathFromRoot: [{ params: { site: 'demo' } }] }); + const state = makeRouterStateSnapshot('/cached-ssr'); + + await TestBed.runInInjectionContext(async () => { + return ( + resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise + )(route, state); + }); + const second = await TestBed.runInInjectionContext(async () => { + return ( + resolver as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => Promise + )(route, state); + }); + + expect(cachedLoader).toHaveBeenCalledTimes(1); + expect(second).toEqual({ cached: true }); + }); }); describe('server with REQUEST', () => { @@ -370,8 +411,9 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: loaderWithRequest } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST, useValue: mockRequest }, + provideServerLoaderRunner(), ], }); }); @@ -419,7 +461,8 @@ describe('loaderResolver', () => { TransferState, { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, - { provide: LoaderDataService, useValue: { getData: vi.fn() } }, + { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + provideServerLoaderRunner(), { provide: SITECORE_CONFIG_TOKEN, useValue: { diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 9e1c8bd2f1..d04c09d4e3 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -8,19 +8,20 @@ import { Router, RedirectCommand, } from '@angular/router'; -import { LOADER_REGISTRY, LOADER_ID } from './loader-registry.token'; -import { LoaderDataService } from './loader-data.service'; +import { LOADER_ID } from './loader-registry.token'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { extractRequestContext, applyRedirect } from './utils'; import { DEFAULT_ERROR_ROUTE, DEFAULT_NOT_FOUND_ROUTE, LoaderHttpError, NotFoundNavigationError, - isLoaderRedirectResult, + PerRouteLoaderCacheConfig, } from './models'; import { redirectOnNavigationError } from './router-error-handling'; -import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN, SITECORE_CONFIG_TOKEN } from '../lib/tokens'; - +import { ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN } from '../lib/tokens'; +import { SERVER_LOADER_RUNNER } from './server-loader-runner.token'; +import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; /** * Create a state key for the loader * @param {string} loaderId - The loader ID @@ -65,23 +66,33 @@ function buildLoaderParams(route: ActivatedRouteSnapshot, defaultLanguage?: stri } /** - * Browser-only: load data from transfer state or LoaderDataService. - * Injects TransferState, LoaderDataService. Called by the resolver when isPlatformBrowser. + * Browser-only: load data from transfer state or ClientLoaderDataService. + * Injects TransferState, ClientLoaderDataService. Called by the resolver when isPlatformBrowser. * @param {ActivatedRouteSnapshot} route - The current route snapshot * @param {RouterStateSnapshot} state - The router state snapshot - * @param {string} loaderId - loader ID to resolve, used for transfer state key and LoaderDataService call + * @param {string} loaderId - loader ID to resolve, used for transfer state key and ClientLoaderDataService call * @param {Router} router - The Angular router instance * @param {string} [defaultLanguage] - Default language for locale fallback in params + * @param {LoaderCacheConfig} [cacheOptions] - Cache options for the loader + * @returns {Promise} The resolved data or redirect command */ -async function resolveOnBrowser( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - loaderId: string, - router: Router, - defaultLanguage?: string -): Promise { +async function resolveOnBrowser({ + route, + state, + loaderId, + router, + defaultLanguage, + cacheOptions, +}: { + route: ActivatedRouteSnapshot; + state: RouterStateSnapshot; + loaderId: string; + router: Router; + defaultLanguage?: string; + cacheOptions?: PerRouteLoaderCacheConfig; +}): Promise { const transferState = inject(TransferState); - const loaderData = inject(LoaderDataService); + const browserLoaderData = inject(ClientLoaderDataService); const url = state.url; const key = stateKey(loaderId, url); @@ -94,11 +105,12 @@ async function resolveOnBrowser( const allParams = buildLoaderParams(route, defaultLanguage); - const resp = await loaderData.getData({ + const resp = await browserLoaderData.getData({ url, loaderId, params: allParams, query: route.queryParams as Record, + cacheOptions, }); if (resp.kind === 'error') { @@ -113,11 +125,19 @@ async function resolveOnBrowser( return resp.data; } -export const loaderResolver = (loaderId: LoaderId): ResolveFn => { +/** + * Create a loader resolver function that resolver loader data with optional cache options on server or browser. + * @param {LoaderId} loaderId - The loader ID + * @param {PerRouteLoaderCacheConfig} [cacheOptions] - The cache options + * @returns {ResolveFn} loader resolver function + */ +export const loaderResolver = ( + loaderId: LoaderId, + cacheOptions?: PerRouteLoaderCacheConfig +): ResolveFn => { const resolver = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const transferState = inject(TransferState); const platformId = inject(PLATFORM_ID); - const registry = inject(LOADER_REGISTRY); const request = inject(REQUEST, { optional: true }); const notFoundRoute = inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE; @@ -130,32 +150,51 @@ export const loaderResolver = (loaderId: LoaderId): ResolveFn => { if (isPlatformBrowser(platformId)) { try { - return await resolveOnBrowser(route, state, loaderId, router, defaultLanguage); + return await resolveOnBrowser({ + route, + state, + loaderId, + router, + defaultLanguage, + cacheOptions, + }); } catch (e) { // special handling for browser, as navigation error for handleNavigationError is only generated on server return redirectOnNavigationError(e as Error, url, notFoundRoute, errorRoute, router); } } - const loader = registry[loaderId]; - - if (!loader) { - throw new Error(`No loader registered for id "${loaderId}"`); + const serverLoaderRunner = inject(SERVER_LOADER_RUNNER, { optional: true }); + if (!serverLoaderRunner) { + throw new Error( + 'SSR loader resolution requires provideServerLoaderRunner() in server application providers' + ); } - const requestContext = request ? extractRequestContext(request) : undefined; + const angularRequestContext = request ? extractRequestContext(request) : undefined; - const result = await loader({ + const result = await serverLoaderRunner.resolve({ + loaderId, url, params: buildLoaderParams(route, defaultLanguage), - query: route.queryParams, - requestContext, + query: route.queryParams as Record, + angularRequestContext, + cacheOptions, }); - if (isLoaderRedirectResult(result)) { - return applyRedirect(router, result.loaderRedirectTarget); + + if (result.kind === 'redirect') { + return applyRedirect(router, result.redirect.loaderRedirectTarget); + } + + if (result.kind === 'error') { + const cause = result.cause; + if (cause instanceof NotFoundNavigationError) throw cause; + if (cause instanceof LoaderHttpError) throw cause; + throw new LoaderHttpError(result.status, result.message); } - transferState.set(key, result); - return result; + + transferState.set(key, result.data); + return result.data; }; resolver[LOADER_ID] = loaderId; diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index 860ee981f7..3fde4cc888 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -1,5 +1,4 @@ import type { Params } from '@angular/router'; - export const DEFAULT_NOT_FOUND_ROUTE = '/404'; export const DEFAULT_ERROR_ROUTE = '/500'; @@ -83,6 +82,19 @@ export type LoaderApiRequest = { url: string; params: Params; query: Record; + /** + * Server-derived request context (hostname, headers, cookies, query). + * Populated once at the request boundary (`/_data` middleware closure or the + * SSR resolver). Downstream code reads this directly; nobody re-extracts. + * Phase 2 of the refactor plan. + */ + angularRequestContext?: RequestContext; + /** + * Per-route cache overrides supplied at the `loaderResolver(id, cacheOptions)` + * call site. The browser includes them in the `/_data` POST body so the same + * per-route policy applies on CSR navigations. Phase 5 of the refactor plan. + */ + cacheOptions?: LoaderCacheConfig; }; export type LoaderRedirectResult = { @@ -110,6 +122,16 @@ export type LoaderApiResponse = | { kind: 'error'; status: number; message: string } | { kind: 'notFound'; status: number }; +/** + * Result returned by loader resolution on the server (SSR and `/_data` endpoint). + * Uses the shared cross-boundary loader registry; not a separate server loader set. + * @public + */ +export type LoaderDataResult = + | { kind: 'data'; data: unknown } + | { kind: 'redirect'; redirect: LoaderRedirectResult } + | { kind: 'error'; status: number; message: string; cause?: unknown }; + /** * Loader function type. * A loader is an async function that receives context, can be applied in route resolvers and can return: @@ -131,3 +153,137 @@ export class LoaderHttpError extends Error { super(message); } } + +/** + * Base browser-safe config type for loader cache. + * + * `revalidate` is in seconds. A positive value caches the entry for that many + * seconds; `0` or a negative value means "never expire" (rely on explicit + * invalidation). There is no `'infinite'` sentinel. + * @public + */ +export interface LoaderCacheConfig extends PerRouteLoaderCacheConfig { + /** Default site name for tag helpers and admin tooling. Defaults to `'default'`. */ + defaultSiteName?: string; + /** + * Site names used by revalidation middleware to fan out dictionary loader tags + * (`sc:loader:dictionary::`) on every webhook call. + */ + sites?: string[]; + /** Fallback locale for tag helpers when a site entry has no `language`. Defaults to `'en'`. */ + defaultLocale?: string; +} + +/** + * Per-route cache configuration. + * @public + */ +export interface PerRouteLoaderCacheConfig { + /** TTL in seconds. Positive → expires after N seconds; `0` or negative → never expires. */ + revalidate?: number; + /** Master switch — when false, every call falls through to the raw loader. */ + enabled?: boolean; + /** + * Custom tags applied to every entry this loader writes. Merged with built-in + * OSR tags (self-key, `sc:site`, `sc:locale`, and `sc:item` for page loaders). + */ + tags?: string[]; +} + +/** + * Metadata returned by cache.entries() — sufficient for an admin UI without + * shipping the cached values themselves (which can be large). + * @public + */ +export interface LoaderCacheEntryInfo { + key: string; + tags: string[]; + storedAt: number; + expiresAt: number | null; + stale: boolean; +} + +/** + * Three-outcome read result for stale-while-revalidate (Phase 3). + * + * - `hit` — entry is fresh; serve cached value without running the loader. + * - `stale` — entry expired or was invalidated; serve cached value and refresh in the background. + * - `miss` — no entry; run the loader synchronously. + * @public + */ +export type LoaderCacheReadResult = + /** Fresh cache entry within TTL and not marked stale. */ + | { kind: 'hit'; value: unknown; cacheKey: string } + /** Expired or invalidated entry; value is served while a background refresh runs. */ + | { kind: 'stale'; value: unknown; cacheKey: string } + /** No entry stored for the requested cache key. */ + | { kind: 'miss'; cacheKey: string }; + +/** + * Persisted cache entry shape. Stored under the composite cache key built by + * buildCacheKey(); see cache-key.ts. + * @public + */ +export interface LoaderCacheEntry { + value: unknown; + tags: string[]; + storedAt: number; + expiresAt: number | null; // null = never expire + /** When true (or TTL expired), entry is served stale while refreshing. */ + stale: boolean; +} + +/** + * Tag-based invalidation input. + * Marks matching entries stale via the tag index; does not delete them (SWR semantics). + * @public + */ +export interface InvalidateInput { + /** Non-empty list of OSR tags (for example `sc:item:…`, `sc:site:…`, or a cache key self-tag). */ + tags?: string[]; +} + +/** + * Server-only cache instance. Constructed once in `server.ts` via + * {@link createLoaderCache} and passed by reference to middleware factories + * ({@link createLoaderDataServiceMiddleware}, {@link createCacheAdminMiddleware}, + * {@link createSitecoreRevalidateMiddleware}) and to Angular SSR through + * `angularApp.handle(req, { cache })`. + * + * Implementations maintain a sidecar tag index so {@link LoaderCache.invalidate} + * can mark entries stale without scanning every key. + * @public + */ +export interface LoaderCache { + /** Global default TTL in seconds from {@link LoaderCacheConfig.revalidate}. */ + get ttl(): number; + /** Resolved configuration (useful for admin UI and diagnostics). */ + get config(): Readonly; + /** + * Reads a cache entry and classifies it as hit, stale, or miss. + * @param key - OSR-aligned cache key (for example `sc:loader:page:demo:en:default:about`). + */ + get(key: string): Promise; + /** + * Stores an entry and links it to the supplied tag set. + * @param key - Cache key to write. + * @param value - Loader payload to persist. + * @param ttlSeconds - TTL in seconds; `0` or negative means never expire. + * @param tags - Tag index pointers written alongside the entry (self-key, site, locale, item, etc.). + */ + set(key: string, value: unknown, ttlSeconds: number, tags: string[]): Promise; + /** + * Marks every entry linked to any of the supplied tags as stale. + * @param filter - Tag list to resolve through the tag index. + * @returns Number of entries marked stale (includes entries already stale). + */ + invalidate(filter: InvalidateInput): Promise; + /** Removes a single entry and unlinks it from the tag index. */ + delete(key: string): Promise; + /** Removes every entry and clears the tag index. */ + flush(): Promise; + /** Returns lightweight metadata for admin tooling (values are omitted). */ + entries(): Promise; + /** Whether caching is enabled globally. Per-route overrides may still opt in. */ + enabled(): boolean; +} diff --git a/packages/angular/src/loaders/pre-loader-data.service.spec.ts b/packages/angular/src/loaders/pre-loader-data.service.spec.ts index 019258433a..b60eb1e252 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.spec.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.spec.ts @@ -4,8 +4,8 @@ import { PLATFORM_ID } from '@angular/core'; import { provideRouter } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { PreLoaderDataService } from './pre-loader-data.service'; -import { LoaderDataService } from './loader-data.service'; +import { ClientPreLoaderDataService } from './pre-loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; import { LOADER_ID } from './loader-registry.token'; function makeResolverWithLoaderId(loaderId: string): (() => void) & { [LOADER_ID]: string } { @@ -36,18 +36,18 @@ function makeRouterStateSnapshot(url: string): RouterStateSnapshot { return { url } as RouterStateSnapshot; } -describe('PreLoaderDataService', () => { +describe('ClientPreLoaderDataService', () => { let loaderDataPrefetchSpy: ReturnType; beforeEach(() => { loaderDataPrefetchSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ - PreLoaderDataService, + ClientPreLoaderDataService, provideRouter([]), { provide: PLATFORM_ID, useValue: 'browser' }, { - provide: LoaderDataService, + provide: ClientLoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy }, }, ], @@ -75,7 +75,7 @@ describe('PreLoaderDataService', () => { (child as MutableSnapshot).pathFromRoot = [root, child]; const state = makeRouterStateSnapshot('/page/123'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(child as ActivatedRouteSnapshot, state); @@ -111,7 +111,7 @@ describe('PreLoaderDataService', () => { (child as MutableSnapshot).pathFromRoot = [root, child]; const state = makeRouterStateSnapshot('/page'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(child as ActivatedRouteSnapshot, state); @@ -128,10 +128,10 @@ describe('PreLoaderDataService', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ - PreLoaderDataService, + ClientPreLoaderDataService, provideRouter([]), { provide: PLATFORM_ID, useValue: 'server' }, - { provide: LoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy } }, + { provide: ClientLoaderDataService, useValue: { prefetch: loaderDataPrefetchSpy } }, ], }); const root = makeRouteSnapshot({ @@ -141,7 +141,7 @@ describe('PreLoaderDataService', () => { }); (root as MutableSnapshot).pathFromRoot = [root]; const state = makeRouterStateSnapshot('/'); - const service = TestBed.inject(PreLoaderDataService); + const service = TestBed.inject(ClientPreLoaderDataService); await service.prefetchForRoute(root as ActivatedRouteSnapshot, state); diff --git a/packages/angular/src/loaders/pre-loader-data.service.ts b/packages/angular/src/loaders/pre-loader-data.service.ts index dcb590a8ed..4a32c91611 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.ts @@ -9,8 +9,8 @@ import { Router, RouterStateSnapshot, } from '@angular/router'; -import { LoaderDataService } from './loader-data.service'; -import { LoaderDataRequest } from './loader-data.service'; +import { ClientLoaderDataService } from './client-loader-data.service'; +import { LoaderDataRequest } from './client-loader-data.service'; import { LOADER_ID } from './loader-registry.token'; /** @@ -22,22 +22,22 @@ interface ResolverWithLoaderId { } /** - * PreLoaderDataService kicks off loader data fetches for all loaders in the current route + * ClientPreLoaderDataService kicks off loader data fetches for all loaders in the current route * and its parent routes in parallel, so that when Angular runs resolvers sequentially, - * resolvers get cache hits or join already-pending requests instead of waiting. + * resolvers get staged prefetched responses or join already-pending requests instead of waiting. * * Subscribes to the router's ActivationStart event and prefetches for the * ActivatedRouteSnapshot when it is the leaf route (browser only). Discovers all loader * resolvers on that snapshot and its parents (via LOADER_ID on pathFromRoot), then - * calls LoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches - * run in parallel; results are stored in LoaderDataService cache for getData() to consume. + * calls ClientLoaderDataService.prefetch() for each (loaderId, url, params, query). Fetches + * run in parallel; results are stored in ClientLoaderDataService prefetchedResponses for getData() to consume. * @public */ @Injectable({ providedIn: 'root', }) -export class PreLoaderDataService { - private readonly loaderData = inject(LoaderDataService); +export class ClientPreLoaderDataService { + private readonly loaderData = inject(ClientLoaderDataService); private readonly platformId = inject(PLATFORM_ID); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); diff --git a/packages/angular/src/loaders/server-loader-runner.token.ts b/packages/angular/src/loaders/server-loader-runner.token.ts new file mode 100644 index 0000000000..5821efc33e --- /dev/null +++ b/packages/angular/src/loaders/server-loader-runner.token.ts @@ -0,0 +1,26 @@ +import { InjectionToken } from '@angular/core'; +import { LoaderApiRequest, LoaderDataResult } from './models'; + +/** + * SSR injection port for cache-aware loader resolution. + * Implemented by {@link ServerLoaderDataProvider} and wired via + * {@link provideServerLoaderDataProvider}. + * @public + */ +export interface ServerLoaderRunnerPort { + /** + * Resolve loader data on the server (cache-aware) using the shared {@link LOADER_REGISTRY}. + * @param {LoaderApiRequest} request - Loader request payload + * @returns {Promise} Resolved loader result + */ + resolve(request: LoaderApiRequest): Promise; +} + +/** + * Injection token for SSR loader data resolution. + * Must be provided via `provideServerLoaderDataProvider()` in server application config. + * @public + */ +export const SERVER_LOADER_RUNNER = new InjectionToken( + 'SERVER_LOADER_RUNNER' +); diff --git a/packages/angular/src/loaders/utils.ts b/packages/angular/src/loaders/utils.ts index b09cddefce..7828eae8a9 100644 --- a/packages/angular/src/loaders/utils.ts +++ b/packages/angular/src/loaders/utils.ts @@ -129,10 +129,25 @@ export function extractRequestContext(req: Request | ExpressLikeRequest): Reques }; } - // Express-like request object + const hostHeader = req.headers?.host; + const hostname = pickHostnameFromHostHeader( + Array.isArray(hostHeader) ? hostHeader[0] : hostHeader + ); return { + hostname, headers: req.headers, cookies: req.cookies, query: req.query, }; } + +/** + * Pick the hostname from the host header + * @param {string | undefined} host - The host header + * @returns {string | undefined} The hostname + */ +function pickHostnameFromHostHeader(host: string | undefined): string | undefined { + if (!host) return undefined; + const colon = host.indexOf(':'); + return colon === -1 ? host : host.slice(0, colon); +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index e24d4d6d89..a98d307d4f 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -71,13 +71,21 @@ import { Router } from '@angular/router'; // Angular-specific exports export * from './loaders/loader-resolver'; export * from './loaders/loader-registry.token'; -export * from './loaders/loader-data.service'; +export * from './loaders/client-loader-data.service'; export * from './loaders/pre-loader-data.service'; +export { + SERVER_LOADER_RUNNER, + type ServerLoaderRunnerPort, +} from './loaders/server-loader-runner.token'; +export { type LoaderRegistry } from './loaders/loader-registry.token'; export { NotFoundNavigationError, LoaderHttpError, type LoaderFn, type LoaderContext, + type LoaderDataResult, + type PerRouteLoaderCacheConfig, + type LoaderCacheConfig, } from './loaders/models'; export { handleNavigationError } from './loaders/router-error-handling'; export { applyRedirect } from './loaders/utils'; diff --git a/packages/angular/src/server/cache/cache-key.spec.ts b/packages/angular/src/server/cache/cache-key.spec.ts new file mode 100644 index 0000000000..13ef47c2fd --- /dev/null +++ b/packages/angular/src/server/cache/cache-key.spec.ts @@ -0,0 +1,85 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import type { LoaderContext } from '../../loaders/models'; +import { + buildCacheKey, + buildPageCacheKey, + buildDictionaryCacheKey, + CACHE_KEY_PREFIX, + serializeLoaderCacheKey, +} from './cache-key'; +import type { CacheKeyDimensions } from './models'; + +function makeContext(overrides: Partial = {}): LoaderContext { + return { + url: '/about', + params: { site: 'mysite', locale: 'en' }, + query: {}, + ...overrides, + }; +} + +describe('buildCacheKey', () => { + it('builds sc:loader:page key from site, locale, variant, and pathKey', () => { + const { key, dimensions } = buildCacheKey('page', makeContext({ url: '/about?preview=1' })); + + expect(dimensions).toEqual({ + site: 'mysite', + locale: 'en', + variantId: 'default', + loaderId: 'page', + pathKey: 'about', + }); + expect(key).toBe('sc:loader:page:mysite:en:default:about'); + }); + + it('uses _ pathKey for home route', () => { + const { dimensions } = buildCacheKey('page', makeContext({ url: '/' })); + expect(dimensions.pathKey).toBe('_'); + }); + + it('strips locale prefix from url when it matches params.locale', () => { + const { dimensions } = buildCacheKey( + 'page', + makeContext({ url: '/en/about', params: { site: 'mysite', locale: 'en' } }) + ); + expect(dimensions.pathKey).toBe('about'); + }); + + it('builds dictionary key without variant or path', () => { + const { key } = buildCacheKey('dictionary', makeContext()); + expect(key).toBe('sc:loader:dictionary:mysite:en'); + }); + + it('defaults site and locale when params omit them', () => { + const { dimensions } = buildCacheKey('page', makeContext({ params: {}, url: '/home' })); + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.pathKey).toBe('home'); + }); +}); + +describe('serializeLoaderCacheKey', () => { + it('dispatches page and dictionary shapes', () => { + const pageDims: CacheKeyDimensions = { + site: 'demo', + locale: 'de', + variantId: 'default', + loaderId: 'page', + pathKey: 'products/shoes', + }; + const dictDims: CacheKeyDimensions = { + site: 'demo', + locale: 'de', + variantId: 'default', + loaderId: 'dictionary', + pathKey: '_', + }; + + expect(buildPageCacheKey(pageDims)).toBe( + `${CACHE_KEY_PREFIX}:page:demo:de:default:products/shoes` + ); + expect(buildDictionaryCacheKey(dictDims)).toBe(`${CACHE_KEY_PREFIX}:dictionary:demo:de`); + expect(serializeLoaderCacheKey(pageDims)).toBe(buildPageCacheKey(pageDims)); + }); +}); diff --git a/packages/angular/src/server/cache/cache-key.ts b/packages/angular/src/server/cache/cache-key.ts new file mode 100644 index 0000000000..823be603d0 --- /dev/null +++ b/packages/angular/src/server/cache/cache-key.ts @@ -0,0 +1,87 @@ +import type { LoaderContext } from '../../loaders/models'; +import { CacheKeyDimensions } from './models'; +import { dimensionsFromContext } from './utils'; +import { sanitizeSitecoreCacheSegment } from './utils'; +import { SITECORE_CONTENT_CACHE_TAG_PREFIX } from './cache-tags'; + +/** Prefix for OSR-aligned loader cache keys (`sc:loader:…`). @internal */ +export const CACHE_KEY_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader`; + +/** + * Compose the canonical cache key and dimension tuple for a loader invocation. + * @param {string} loaderId - Registered loader id (`page`, `dictionary`, etc.). + * @param {LoaderContext} ctx - Loader context (URL, route params, query). + * @returns {{ key: string, dimensions: CacheKeyDimensions }} Cache key and parsed dimensions. + * @example + * ```ts + * buildCacheKey('page', { url: '/about', params: { site: 'demo', locale: 'en' }, query: {} }); + * // → { key: 'sc:loader:page:demo:en:default:about', dimensions: { … } } + * ``` + * @internal + */ +export function buildCacheKey( + loaderId: string, + ctx: LoaderContext +): { key: string; dimensions: CacheKeyDimensions } { + const dimensions = dimensionsFromContext(loaderId, ctx); + const key = serializeLoaderCacheKey(dimensions); + return { key, dimensions }; +} + +/** + * Serializes cache key dimensions into the public `sc:loader:…` format. + * Dispatches to {@link buildPageCacheKey}, {@link buildDictionaryCacheKey}, or + * {@link buildGenericLoaderCacheKey} based on `dimensions.loaderId`. + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} OSR-aligned cache key. + * @internal + */ +export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string { + if (dimensions.loaderId === 'page') { + return buildPageCacheKey(dimensions); + } + if (dimensions.loaderId === 'dictionary') { + return buildDictionaryCacheKey(dimensions); + } + return buildGenericLoaderCacheKey(dimensions); +} + +/** + * Page loader key: `sc:loader:page::::`. + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Page loader cache key. + * @internal + */ +export function buildPageCacheKey(dimensions: CacheKeyDimensions): string { + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId); + return `${CACHE_KEY_PREFIX}:page:${site}:${locale}:${variantId}:${dimensions.pathKey}`; +} + +/** + * Dictionary loader key: `sc:loader:dictionary::` (one entry per site/locale). + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Dictionary loader cache key. + * @internal + */ +export function buildDictionaryCacheKey(dimensions: CacheKeyDimensions): string { + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + return `${CACHE_KEY_PREFIX}:dictionary:${site}:${locale}`; +} + +/** + * Generic loader key: `sc:loader:::::`. + * Used for loaders other than `page` and `dictionary` (for example `404`, `500`). + * @param {CacheKeyDimensions} dimensions - Parsed cache key dimensions. + * @returns {string} Generic loader cache key. + * @internal + */ +export function buildGenericLoaderCacheKey(dimensions: CacheKeyDimensions): string { + const loaderId = sanitizeSitecoreCacheSegment(dimensions.loaderId); + const site = sanitizeSitecoreCacheSegment(dimensions.site); + const locale = sanitizeSitecoreCacheSegment(dimensions.locale); + const variantId = sanitizeSitecoreCacheSegment(dimensions.variantId); + return `${CACHE_KEY_PREFIX}:${loaderId}:${site}:${locale}:${variantId}:${dimensions.pathKey}`; +} diff --git a/packages/angular/src/server/cache/cache-tags.spec.ts b/packages/angular/src/server/cache/cache-tags.spec.ts new file mode 100644 index 0000000000..995362b4df --- /dev/null +++ b/packages/angular/src/server/cache/cache-tags.spec.ts @@ -0,0 +1,159 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import type { RouteData } from '@sitecore-content-sdk/content/layout'; +import { + SITECORE_CONTENT_CACHE_TAG_PREFIX, + buildSitecoreItemCacheTag, + buildSitecoreDictionaryCacheTag, + buildSitecoreItemCacheTagFromRouteData, + buildLoaderDictionaryCacheTagsFromSites, + buildLoaderDictionaryCacheTag, + buildSitecoreSiteCacheTag, + buildSitecoreLocaleCacheTag, + buildLoaderCacheTags, +} from './cache-tags'; +import type { CacheKeyDimensions } from './models'; + +const pageDimensions: CacheKeyDimensions = { + site: 'Demo Site', + locale: 'en-US', + variantId: 'default', + loaderId: 'page', + pathKey: 'about', +}; + +describe('buildSitecoreItemCacheTag', () => { + it('normalizes item id and builds latest version tag by default', () => { + expect( + buildSitecoreItemCacheTag({ itemId: '{ABC-123}', locale: 'en-US', version: undefined }) + ).toBe(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:abc-123:en-us:latest`); + }); + + it('includes numeric version when provided', () => { + expect(buildSitecoreItemCacheTag({ itemId: 'abc', locale: 'en', version: 3.7 })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:abc:en:v3` + ); + }); +}); + +describe('buildSitecoreDictionaryCacheTag', () => { + it('sanitizes site and locale segments', () => { + expect(buildSitecoreDictionaryCacheTag({ site: 'Demo Site', locale: 'en US' })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:demo_site:en_us` + ); + }); +}); + +describe('buildSitecoreItemCacheTagFromRouteData', () => { + it('returns null when route has no itemId', () => { + expect(buildSitecoreItemCacheTagFromRouteData(null, 'en')).toBeNull(); + expect(buildSitecoreItemCacheTagFromRouteData({} as RouteData, 'en')).toBeNull(); + }); + + it('uses route language and version when present', () => { + const route = { + itemId: '{GUID}', + itemLanguage: 'de', + itemVersion: 5, + } as RouteData; + + expect(buildSitecoreItemCacheTagFromRouteData(route, 'en')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:guid:de:v5` + ); + }); + + it('falls back to provided locale when route language is absent', () => { + const route = { itemId: 'item-1' } as RouteData; + expect(buildSitecoreItemCacheTagFromRouteData(route, 'fr-CA')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:item-1:fr-ca:latest` + ); + }); +}); + +describe('buildLoaderDictionaryCacheTagsFromSites', () => { + it('dedupes tags and falls back to base locale', () => { + const tags = buildLoaderDictionaryCacheTagsFromSites({ + sites: [ + { name: 'shop', language: 'en' }, + { name: 'shop', language: 'en' }, + { name: 'blog', language: ' ' }, + ], + baseLocale: 'de', + }); + + expect(tags).toEqual([ + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:shop:en`, + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:blog:de`, + ]); + }); +}); + +describe('buildLoaderDictionaryCacheTag', () => { + it('builds loader dictionary self-tag', () => { + expect(buildLoaderDictionaryCacheTag({ site: 'demo', locale: 'en' })).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:demo:en` + ); + }); +}); + +describe('buildSitecoreSiteCacheTag / buildSitecoreLocaleCacheTag', () => { + it('sanitizes site and locale fan-out tags', () => { + expect(buildSitecoreSiteCacheTag('My Site')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:my_site` + ); + expect(buildSitecoreLocaleCacheTag('en US')).toBe( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:en_us` + ); + }); +}); + +describe('buildLoaderCacheTags', () => { + const cacheKey = 'sc:loader:page:demo:en:default:about'; + + it('includes site, locale, self-key, and custom tags', () => { + const tags = buildLoaderCacheTags('footer', pageDimensions, cacheKey, undefined, [ + 'custom:tag', + cacheKey, + ]); + + expect(tags).toContain(cacheKey); + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:demo_site`); + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:en-us`); + expect(tags).toContain('custom:tag'); + expect(tags.length).toBe(new Set(tags).size); + }); + + it('adds item tag for page loader when layout route has itemId', () => { + const pageValue = { + layout: { + sitecore: { + route: { + itemId: '{ITEM-1}', + itemLanguage: 'en', + }, + }, + }, + }; + + const tags = buildLoaderCacheTags('page', pageDimensions, cacheKey, pageValue); + + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:item-1:en:latest`); + }); + + it('skips item tag for page loader when value is not a page shape', () => { + const tags = buildLoaderCacheTags('page', pageDimensions, cacheKey, 'not-a-page'); + expect(tags.some((tag) => tag.includes(':item:'))).toBe(false); + }); + + it('adds dictionary tag for dictionary loader', () => { + const dictDimensions: CacheKeyDimensions = { + ...pageDimensions, + loaderId: 'dictionary', + }; + const dictKey = 'sc:loader:dictionary:demo:en'; + + const tags = buildLoaderCacheTags('dictionary', dictDimensions, dictKey); + + expect(tags).toContain(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:demo_site:en-us`); + }); +}); diff --git a/packages/angular/src/server/cache/cache-tags.ts b/packages/angular/src/server/cache/cache-tags.ts new file mode 100644 index 0000000000..caadb44c1b --- /dev/null +++ b/packages/angular/src/server/cache/cache-tags.ts @@ -0,0 +1,230 @@ +import type { RouteData } from '@sitecore-content-sdk/content/layout'; +import type { Page } from '@sitecore-content-sdk/content/client'; +import { + normalizeSitecoreItemIdForCacheKey, + sanitizeSitecoreCacheSegment, + dedupeCacheStrings, +} from './utils'; +import type { CacheKeyDimensions } from './models'; + +/** + * Sitecore OSR namespace prefix shared with Next.js (`sc:`). + * All loader cache keys and invalidation tags use this prefix. + * @internal + */ +export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; + +/** + * Parameters for {@link buildSitecoreItemCacheTag}. + * @internal + */ +export type BuildSitecoreItemCacheTagParams = { + /** Sitecore item GUID or content id. */ + itemId: string; + /** Locale/culture for the item tag. */ + locale: string; + /** Optional published version; omitted values produce a `latest` suffix. */ + version?: number; +}; + +/** + * Builds an item-scoped revalidation tag: `sc:item:::`. + * @param {BuildSitecoreItemCacheTagParams} params - Item id, locale, and optional version. + * @returns {string} Sitecore item cache tag. + * @internal + */ +export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParams): string { + const id = normalizeSitecoreItemIdForCacheKey(params.itemId); + const locale = sanitizeSitecoreCacheSegment(params.locale); + const ver = + params.version !== undefined && Number.isFinite(params.version) + ? `v${Math.trunc(params.version)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +/** + * Parameters for {@link buildSitecoreDictionaryCacheTag} and related dictionary tag helpers. + * @internal + */ +export type SitecoreDictionaryCacheTagParams = { + /** Site name segment. */ + site: string; + /** Locale segment. */ + locale: string; +}; + +/** + * Site entry used when fanning out dictionary loader tags from webhook middleware. + * @internal + */ +export type LoaderDictionaryCacheSiteInfo = { + /** Site name. */ + name: string; + /** Optional site language; falls back to `baseLocale` when blank. */ + language?: string; +}; + +/** + * Parameters for {@link buildLoaderDictionaryCacheTagsFromSites}. + * @internal + */ +export type BuildLoaderDictionaryCacheTagsFromSitesParams = { + /** Sites to emit dictionary loader tags for. */ + sites: readonly LoaderDictionaryCacheSiteInfo[]; + /** Locale used when a site entry has no `language`. */ + baseLocale: string; +}; + +/** + * Builds a Next.js-compatible dictionary tag: `sc:dict::`. + * Used for dictionary loader entries and cross-stack webhook fan-out. + * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. + * @returns {string} Dictionary cache tag. + * @internal + */ +export function buildSitecoreDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { + const site = sanitizeSitecoreCacheSegment(params.site); + const locale = sanitizeSitecoreCacheSegment(params.locale); + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; +} + +/** + * Builds an item tag from layout route data when `itemId` is present. + * Returns `null` when the route has no item id (non-content routes). + * @param {RouteData | null | undefined} route - Layout route metadata. + * @param {string} fallbackLocale - Locale used when `route.itemLanguage` is absent. + * @returns {string | null} Item cache tag, or `null` when no item id is available. + * @internal + */ +export function buildSitecoreItemCacheTagFromRouteData( + route: RouteData | null | undefined, + fallbackLocale: string +): string | null { + if (!route?.itemId) { + return null; + } + const locale = route.itemLanguage + ? sanitizeSitecoreCacheSegment(route.itemLanguage) + : sanitizeSitecoreCacheSegment(fallbackLocale); + const id = normalizeSitecoreItemIdForCacheKey(route.itemId); + const ver = + route.itemVersion !== undefined && Number.isFinite(route.itemVersion) + ? `v${Math.trunc(route.itemVersion)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +/** + * Builds loader-cache dictionary self-tags for webhook fan-out across sites. + * Produces `sc:loader:dictionary::` tags, deduped in first-seen order. + * When a site has no `language`, `baseLocale` is used. + * @param {BuildLoaderDictionaryCacheTagsFromSitesParams} params - Sites and fallback locale. + * @returns {string[]} Deduplicated loader dictionary cache tags. + * @internal + */ +export function buildLoaderDictionaryCacheTagsFromSites( + params: BuildLoaderDictionaryCacheTagsFromSitesParams +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const site of params.sites) { + const locale = site.language?.trim() ? site.language : params.baseLocale; + const tag = buildLoaderDictionaryCacheTag({ site: site.name, locale }); + if (!seen.has(tag)) { + seen.add(tag); + out.push(tag); + } + } + return out; +} + +/** + * Loader-cache self-tag for the dictionary loader: `sc:loader:dictionary::`. + * @param {SitecoreDictionaryCacheTagParams} params - Site and locale segments. + * @returns {string} Loader dictionary self-tag (same shape as the cache key). + * @internal + */ +export function buildLoaderDictionaryCacheTag(params: SitecoreDictionaryCacheTagParams): string { + const site = sanitizeSitecoreCacheSegment(params.site); + const locale = sanitizeSitecoreCacheSegment(params.locale); + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:loader:dictionary:${site}:${locale}`; +} + +/** + * Site-wide fan-out tag: `sc:site:`. + * Invalidating this tag marks every cached entry for the site stale. + * @param {string} site - Site name segment. + * @returns {string} Site fan-out cache tag. + * @internal + */ +export function buildSitecoreSiteCacheTag(site: string): string { + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:site:${sanitizeSitecoreCacheSegment(site)}`; +} + +/** + * Locale-wide fan-out tag: `sc:locale:`. + * @param {string} locale - Locale segment. + * @returns {string} Locale fan-out cache tag. + * @internal + */ +export function buildSitecoreLocaleCacheTag(locale: string): string { + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:locale:${sanitizeSitecoreCacheSegment(locale)}`; +} + +/** + * Builds the full tag set written alongside a loader cache entry (Phase 3 OSR alignment). + * Always includes self-tag, `sc:site:`, and `sc:locale:`. Conditionally adds + * `sc:item:…` for page loaders and `sc:dict:…` for dictionary loaders. Custom tags are deduped. + * @param {string} loaderId - Loader that produced the value. + * @param {CacheKeyDimensions} dimensions - Key dimensions from {@link buildCacheKey}. + * @param {string} cacheKey - Stored cache key (also used as a self-tag). + * @param {unknown} [loaderValue] - Loader payload (page layout is inspected for item tags). + * @param {string[]} [customTags] - Optional per-route tags from `loaderResolver(id, { tags })`. + * @returns {string[]} Tag set to persist with the cache entry. + * @internal + */ +export function buildLoaderCacheTags( + loaderId: string, + dimensions: CacheKeyDimensions, + cacheKey: string, + loaderValue?: unknown, + customTags: string[] = [] +): string[] { + const tags: string[] = [ + cacheKey, + buildSitecoreSiteCacheTag(dimensions.site), + buildSitecoreLocaleCacheTag(dimensions.locale), + ...customTags, + ]; + + if (loaderId === 'page') { + const itemTag = buildPageItemTag(loaderValue, dimensions.locale); + if (itemTag) { + tags.push(itemTag); + } + } + + if (loaderId === 'dictionary') { + tags.push( + buildSitecoreDictionaryCacheTag({ site: dimensions.site, locale: dimensions.locale }) + ); + } + + return dedupeCacheStrings(tags); +} + +/** + * Extracts a page item tag from a loader payload when layout route data is present. + * @param {unknown} value - Loader result (expected to be a page shape). + * @param {string} fallbackLocale - Locale used when route language is absent. + * @returns {string | null} Item cache tag, or `null` when no item id is available. + * @internal + */ +function buildPageItemTag(value: unknown, fallbackLocale: string): string | null { + if (!value || typeof value !== 'object') { + return null; + } + const page = value as Page; + return buildSitecoreItemCacheTagFromRouteData(page.layout?.sitecore?.route, fallbackLocale); +} diff --git a/packages/angular/src/server/cache/cache.spec-helpers.ts b/packages/angular/src/server/cache/cache.spec-helpers.ts new file mode 100644 index 0000000000..12c9ead165 --- /dev/null +++ b/packages/angular/src/server/cache/cache.spec-helpers.ts @@ -0,0 +1,156 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { LoaderCache, LoaderContext } from '../../loaders/models'; +import { buildCacheKey } from './cache-key'; +import { buildLoaderCacheTags } from './cache-tags'; + +export const sampleContext: LoaderContext = { + url: '/products', + params: { site: 'shop', locale: 'en' }, + query: {}, +}; + +export function sampleKey(loaderId = 'page'): string { + return buildCacheKey(loaderId, sampleContext).key; +} + +export function sampleTags(loaderId = 'page', value?: unknown): string[] { + const { key, dimensions } = buildCacheKey(loaderId, sampleContext); + return buildLoaderCacheTags(loaderId, dimensions, key, value); +} + +export function runSharedLoaderCacheContract( + label: string, + createCache: () => LoaderCache | Promise, + cleanup?: () => Promise +): void { + describe(`${label} shared cache contract`, () => { + let cache: LoaderCache; + + beforeEach(async () => { + cache = await createCache(); + }); + + afterEach(async () => { + await cleanup?.(); + vi.useRealTimers(); + }); + + it('returns miss on empty key and hit after set', async () => { + const key = sampleKey(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + + await cache.set(key, { title: 'Products' }, 300, sampleTags()); + expect(await cache.get(key)).toEqual({ + kind: 'hit', + value: { title: 'Products' }, + cacheKey: key, + }); + }); + + it('returns stale after TTL expires without deleting the entry', async () => { + vi.useFakeTimers(); + const key = sampleKey('expiring'); + await cache.set(key, { stale: true }, 30, sampleTags('expiring')); + + vi.advanceTimersByTime(31_000); + expect(await cache.get(key)).toEqual({ + kind: 'stale', + value: { stale: true }, + cacheKey: key, + }); + }); + + it('keeps zero-TTL entries until explicitly invalidated', async () => { + vi.useFakeTimers(); + const key = sampleKey('persistent'); + await cache.set(key, { permanent: true }, 0, sampleTags('persistent')); + + vi.advanceTimersByTime(3_600_000); + expect((await cache.get(key)).kind).toBe('hit'); + }); + + it('marks matching entries stale by site tag without deleting them', async () => { + const keyA = sampleKey('page'); + const keyB = buildCacheKey('footer', { ...sampleContext, url: '/other' }).key; + + await cache.set(keyA, { page: true }, 300, sampleTags('page')); + const tagsB = buildLoaderCacheTags( + 'footer', + buildCacheKey('footer', { ...sampleContext, url: '/other' }).dimensions, + keyB + ); + await cache.set(keyB, { footer: true }, 300, tagsB); + + expect(await cache.invalidate({ tags: ['sc:site:shop'] })).toBe(2); + expect((await cache.get(keyA)).kind).toBe('stale'); + expect((await cache.get(keyB)).kind).toBe('stale'); + }); + + it('marks a single entry stale by self-key tag', async () => { + const key = sampleKey('page'); + await cache.set(key, { page: true }, 300, sampleTags('page')); + + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('counts already stale entries during invalidate without rewriting them', async () => { + const key = sampleKey('stale-twice'); + await cache.set(key, { value: 1 }, 300, sampleTags('stale-twice')); + + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect(await cache.invalidate({ tags: [key] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('deletes a key and unlinks it from the tag index', async () => { + const key = sampleKey('delete-me'); + await cache.set(key, { temp: true }, 300, sampleTags('delete-me')); + + expect(await cache.delete(key)).toBe(true); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + expect(await cache.delete(key)).toBe(false); + }); + + it('flushes every entry', async () => { + const key = sampleKey('flush-me'); + await cache.set(key, { temp: true }, 300, sampleTags('flush-me')); + await cache.flush(); + expect(await cache.get(key)).toEqual({ kind: 'miss', cacheKey: key }); + }); + + it('returns zero from invalidate when tags are empty', async () => { + const key = sampleKey('no-tags'); + await cache.set(key, { keep: true }, 300, sampleTags('no-tags')); + expect(await cache.invalidate({ tags: [] })).toBe(0); + expect((await cache.get(key)).kind).toBe('hit'); + }); + + it('relinks tag index when overwriting an entry', async () => { + const key = sampleKey('overwrite'); + await cache.set(key, { v: 1 }, 300, ['sc:site:old']); + await cache.set(key, { v: 2 }, 300, ['sc:site:new']); + + expect(await cache.invalidate({ tags: ['sc:site:old'] })).toBe(0); + expect(await cache.invalidate({ tags: ['sc:site:new'] })).toBe(1); + expect((await cache.get(key)).kind).toBe('stale'); + }); + + it('lists entry metadata without values', async () => { + const liveKey = sampleKey('live'); + await cache.set(liveKey, { live: true }, 300, sampleTags('live')); + await cache.invalidate({ tags: [liveKey] }); + + const live = (await cache.entries()).find((entry) => entry.key === liveKey); + expect(live?.tags).toEqual(sampleTags('live')); + expect(live?.stale).toBe(true); + }); + + it('exposes resolved config and ttl', () => { + expect(cache.enabled()).toBe(true); + expect(cache.ttl).toBe(300); + expect(cache.config).toMatchObject({ revalidate: 300, defaultSiteName: 'default' }); + }); + }); +} diff --git a/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts new file mode 100644 index 0000000000..3caba0d4b4 --- /dev/null +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts @@ -0,0 +1,228 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCacheAdminMiddleware } from './cache-admin-middleware'; +import { createLoaderCache } from '../loader-cache'; +import { buildCacheKey } from '../cache-key'; +import { buildLoaderCacheTags } from '../cache-tags'; +import type { ExpressRequest, ExpressResponse } from '../../models'; + +function createMockRes() { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { + status: ReturnType; + json: ReturnType; + }; +} + +function createMockNext() { + return vi.fn(); +} + +describe('createCacheAdminMiddleware', () => { + const endpoint = '/api/_cache'; + let cache: ReturnType; + let cacheKey: string; + + beforeEach(async () => { + cache = createLoaderCache({ revalidate: 300, defaultSiteName: 'demo' }); + const ctx = { + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + const built = buildCacheKey('page', ctx); + cacheKey = built.key; + await cache.set( + cacheKey, + { title: 'About' }, + 300, + buildLoaderCacheTags('page', built.dimensions, cacheKey) + ); + }); + + it('delegates when path is outside admin endpoint', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const next = createMockNext(); + const res = createMockRes(); + + await middleware( + { method: 'GET', path: '/other', url: '/other', body: {}, query: {} } as ExpressRequest, + res, + next + ); + + expect(next).toHaveBeenCalledWith(); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('returns 403 when auth rejects', async () => { + const middleware = createCacheAdminMiddleware({ + cache, + endpoint, + auth: () => false, + }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('lists entries without values', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0] as { entries: Array<{ key: string }> }; + expect(payload.entries.length).toBeGreaterThan(0); + expect(payload.entries[0]).not.toHaveProperty('value'); + }); + + it('requires non-empty tags for invalidate', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'non-empty `tags` array is required' }); + }); + + it('marks matching entries stale by tag', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/invalidate`, + url: `${endpoint}/invalidate`, + body: { tags: [cacheKey] }, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ marked: 1 }); + expect((await cache.get(cacheKey)).kind).toBe('stale'); + }); + + it('returns resolved cache config', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/config`, + url: `${endpoint}/config`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ revalidate: 300 })); + }); + + it('flushes all cache entries', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: `${endpoint}/flush`, + url: `${endpoint}/flush`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + expect((await cache.get(cacheKey)).kind).toBe('miss'); + }); + + it('returns 404 for unknown admin actions', async () => { + const middleware = createCacheAdminMiddleware({ cache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/unknown`, + url: `${endpoint}/unknown`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 500 when cache operations fail', async () => { + const failingCache = createLoaderCache({ revalidate: 300 }); + vi.spyOn(failingCache, 'entries').mockRejectedValue(new Error('storage down')); + + const middleware = createCacheAdminMiddleware({ cache: failingCache, endpoint }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: `${endpoint}/entries`, + url: `${endpoint}/entries`, + body: {}, + query: {}, + } as ExpressRequest, + res, + createMockNext() + ); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'storage down' }); + }); +}); diff --git a/packages/angular/src/server/cache/demo/cache-admin-middleware.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts new file mode 100644 index 0000000000..cc8c1e930c --- /dev/null +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts @@ -0,0 +1,95 @@ +/* eslint-disable */ +/** + * This middleware is only used for testing and should be removed before release. + * TODO: Remove this middleware before release. + */ +import { + ExpressMiddleware, + ExpressNextFunction, + ExpressRequest, + ExpressResponse, +} from '../../models'; +import { InvalidateInput, LoaderCache } from '../../../loaders/models'; + +/** + * Options for the admin middleware. + * @public + */ +export interface CacheAdminMiddlewareOptions { + /** The cache instance to expose. Capture once in `server.ts`. */ + cache: LoaderCache; + /** Base path. Defaults to `/api/_cache`. */ + endpoint?: string; + /** + * Optional auth gate. Return true to allow. Defaults to allowing everything, + * which is fine for local demos — *do not* leave that default in a deploy. + */ + auth?: (req: ExpressRequest) => boolean; +} + +const DEFAULT_ENDPOINT = '/api/_cache'; + +/** + * Lightweight admin surface for the loader cache: + * GET /entries → list entries (metadata only, no values) + * POST /invalidate → mark stale by tags (JSON body) + * POST /flush → flush every entry + * GET /config → resolved config (for the demo UI) + * @public + */ +export function createCacheAdminMiddleware( + options: CacheAdminMiddlewareOptions +): ExpressMiddleware { + const { cache } = options; + const endpoint = options.endpoint ?? DEFAULT_ENDPOINT; + const auth = options.auth ?? (() => true); + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + if (!req.path.startsWith(endpoint + '/')) { + next(); + return; + } + if (!auth(req)) { + res.status(403).json({ error: 'forbidden' }); + return; + } + + const action = req.path.slice(endpoint.length + 1); + + try { + if (action === 'entries' && req.method === 'GET') { + const entries = await cache.entries(); + res.status(200).json({ entries, now: Date.now() }); + return; + } + + if (action === 'config' && req.method === 'GET') { + res.status(200).json({ ...cache.config }); + return; + } + + if (action === 'invalidate' && req.method === 'POST') { + const body = (req.body ?? {}) as Partial; + const hasTags = Array.isArray(body.tags) && body.tags.length > 0; + if (!hasTags) { + res.status(400).json({ error: 'non-empty `tags` array is required' }); + return; + } + const marked = await cache.invalidate(body as InvalidateInput); + res.status(200).json({ marked }); + return; + } + + if (action === 'flush' && req.method === 'POST') { + await cache.flush(); + res.status(200).json({ ok: true }); + return; + } + + res.status(404).json({ error: `unknown cache admin action: ${action}` }); + } catch (err) { + const message = err instanceof Error ? err.message : 'cache admin error'; + res.status(500).json({ error: message }); + } + }; +} diff --git a/packages/angular/src/server/cache/index.ts b/packages/angular/src/server/cache/index.ts new file mode 100644 index 0000000000..44e5f3b5a1 --- /dev/null +++ b/packages/angular/src/server/cache/index.ts @@ -0,0 +1,6 @@ +export type { GlobalLoaderCacheConfig } from './models'; +export { createLoaderCache } from './loader-cache'; +export { + createCacheAdminMiddleware, + type CacheAdminMiddlewareOptions, +} from './demo/cache-admin-middleware'; diff --git a/packages/angular/src/server/cache/loader-cache.spec.ts b/packages/angular/src/server/cache/loader-cache.spec.ts new file mode 100644 index 0000000000..efe5c7e236 --- /dev/null +++ b/packages/angular/src/server/cache/loader-cache.spec.ts @@ -0,0 +1,44 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import memoryDriver from 'unstorage/drivers/memory'; +import { createLoaderCache } from './loader-cache'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { sampleKey, sampleTags } from './cache.spec-helpers'; + +function getStorage(cache: UnstorageLoaderCache): Storage { + return (cache as unknown as { storage: Storage }).storage; +} + +describe('createLoaderCache factory', () => { + it('returns a UnstorageLoaderCache with memory driver when no driver is supplied', async () => { + const cache = createLoaderCache(); + expect(cache).toBeInstanceOf(UnstorageLoaderCache); + const mount = await getStorage(cache as UnstorageLoaderCache).getMount(); + expect(mount?.driver.name).toBe('memory'); + }); + + it('returns an UnstorageLoaderCache when a driver is supplied', () => { + const cache = createLoaderCache({ driver: memoryDriver(), revalidate: 300 }); + expect(cache).toBeInstanceOf(UnstorageLoaderCache); + }); + + it('uses the in-memory backend for get/set when no driver is supplied', async () => { + const cache = createLoaderCache(); + const key = sampleKey('factory-default'); + await cache.set(key, { ok: true }, 300, sampleTags('factory-default')); + expect((await cache.get(key)).kind).toBe('hit'); + }); + + it('uses the unstorage backend for get/set when a driver is supplied', async () => { + const cache = createLoaderCache({ + driver: memoryDriver(), + revalidate: 300, + }); + const key = sampleKey('unstorage'); + await cache.set(key, { persisted: true }, 300, sampleTags('unstorage')); + + expect(await cache.get(key)).toEqual( + expect.objectContaining({ kind: 'hit', value: { persisted: true } }) + ); + }); +}); diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts new file mode 100644 index 0000000000..8c3c77fa06 --- /dev/null +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -0,0 +1,30 @@ +import { LoaderCache } from '../../loaders/models'; +import { GlobalLoaderCacheConfig } from './models'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { resolveConfig } from './utils'; +import memoryDriver from 'unstorage/drivers/memory'; + +/** + * Public factory for the loader cache with unstorage backing. + * Uses the memory driver by default. + * + * Drivers are best imported and constructed in the app's `server.ts` and passed here as an instance. + * Callers depend on the {@link LoaderCache} interface; concrete classes are not exported. + * @param {GlobalLoaderCacheConfig} [config] - Global cache config and optional unstorage driver. + * @returns {LoaderCache} Cache implementation with Phase 3 SWR + tag semantics. + * @example + * ```ts + * const cache = createLoaderCache({ + * revalidate: config.angular.loadersCache.revalidate, + * enabled: config.angular.loadersCache.enabled, + * defaultSiteName: config.defaultSite, + * driver: fsDriver({ base: './.cache/loaders' }), + * }); + * ``` + * @public + */ +export function createLoaderCache(config: GlobalLoaderCacheConfig = {}): LoaderCache { + const resolved = resolveConfig(config); + const driver = config.driver ?? memoryDriver(); + return new UnstorageLoaderCache(driver, resolved); +} diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts new file mode 100644 index 0000000000..fcb93ec03c --- /dev/null +++ b/packages/angular/src/server/cache/models.ts @@ -0,0 +1,42 @@ +import { Driver } from 'unstorage'; +import { LoaderCacheConfig } from '../../loaders/models'; + +/** Default global revalidate TTL (seconds) when {@link LoaderCacheConfig.revalidate} is omitted. @internal */ +export const DEFAULT_CACHE_TTL = 300; + +/** + * Identity dimensions of a cache key. Derived from {@link LoaderContext} by {@link buildCacheKey}. + * @internal + */ +export interface CacheKeyDimensions { + /** Site name from route params (defaults to `'default'`). */ + site: string; + /** Locale from route params (defaults to `'en'`). */ + locale: string; + /** Personalization variant segment (currently always `'default'` until Phase 4). */ + variantId: string; + /** Loader id (`page`, `dictionary`, etc.). */ + loaderId: string; + /** Sanitized path segment from the loader URL; home route uses `'_'`. */ + pathKey: string; +} + +/** + * Global config for the loader cache. Consumed by `createLoaderCache()` in + * the app's `server.ts`. + * + * Moved to separate file to avoid accidental `unstorage` imports in browser-safe code. + * + * Drivers are imported and instantiated in the app (e.g. + * `fsDriver({ base: './.cache/loaders' })`) — the package does not own driver + * selection. When `driver` is omitted, the cache falls back to its built-in + * in-memory implementation. + * @public + */ +export interface GlobalLoaderCacheConfig extends LoaderCacheConfig { + /** + * Unstorage `Driver` instance. Pass an imported driver — the cache wraps it + * with `createStorage({ driver })` internally. Omit for the in-memory default. + */ + driver?: Driver; +} diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts new file mode 100644 index 0000000000..b8ea225998 --- /dev/null +++ b/packages/angular/src/server/cache/unstorage-loader-cache.spec.ts @@ -0,0 +1,132 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import memoryDriver from 'unstorage/drivers/memory'; +import fsDriver from 'unstorage/drivers/fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { UnstorageLoaderCache } from './unstorage-loader-cache'; +import { buildCacheKey } from './cache-key'; +import { + runSharedLoaderCacheContract, + sampleContext, + sampleKey, + sampleTags, +} from './cache.spec-helpers'; + +function getStorage(cache: UnstorageLoaderCache) { + return ( + cache as unknown as { + storage: { + getItem: (key: string) => Promise; + removeItem: (key: string) => Promise; + }; + } + ).storage; +} + +describe('UnstorageLoaderCache', () => { + runSharedLoaderCacheContract( + 'UnstorageLoaderCache (memory driver)', + () => new UnstorageLoaderCache(memoryDriver(), { revalidate: 300, defaultSiteName: 'default' }) + ); + + it('applies config defaults from the constructor', () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 120, enabled: false }); + expect(cache.ttl).toBe(120); + expect(cache.enabled()).toBe(false); + expect(cache.config).toMatchObject({ revalidate: 120, enabled: false }); + }); + + it('returns false when deleting a missing key', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + expect(await cache.delete('sc:loader:page:missing')).toBe(false); + }); + + it('skips missing entries while invalidating stale tags', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('ghost'); + await cache.set(key, { ghost: true }, 300, sampleTags('ghost')); + await cache.delete(key); + + expect(await cache.invalidate({ tags: [key] })).toBe(0); + }); + + it('omits ghost keys from entries listing', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('ghost-entry'); + await cache.set(key, { live: true }, 300, sampleTags('ghost-entry')); + + const storage = getStorage(cache); + await storage.removeItem(key); + + const entries = await cache.entries(); + expect(entries.find((entry) => entry.key === key)).toBeUndefined(); + }); + + it('keeps tag index entries when other keys still reference the tag', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const sharedTag = 'sc:site:shared'; + const keyA = sampleKey('shared-a'); + const keyB = buildCacheKey('footer', { ...sampleContext, url: '/footer' }).key; + + await cache.set(keyA, { a: true }, 300, [sharedTag, keyA]); + await cache.set(keyB, { b: true }, 300, [sharedTag, keyB]); + + expect(await cache.delete(keyA)).toBe(true); + expect(await cache.invalidate({ tags: [sharedTag] })).toBe(1); + expect((await cache.get(keyB)).kind).toBe('stale'); + }); + + it('stores tag index buckets under tag:{tag} keys', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('tag-index'); + const tag = 'sc:site:tag-index'; + + await cache.set(key, { ok: true }, 300, [tag, key]); + + const storage = getStorage(cache); + const indexedKeys = await storage.getItem(`tag:${tag}`); + expect(indexedKeys).toContain(key); + }); + + it('does not duplicate cache keys in a tag bucket when set is called twice', async () => { + const cache = new UnstorageLoaderCache(memoryDriver(), { revalidate: 300 }); + const key = sampleKey('dedupe-tag'); + const tag = 'sc:site:dedupe'; + + await cache.set(key, { v: 1 }, 300, [tag]); + await cache.set(key, { v: 2 }, 300, [tag]); + + const storage = getStorage(cache); + const indexedKeys = await storage.getItem(`tag:${tag}`); + expect(indexedKeys?.filter((entryKey) => entryKey === key)).toHaveLength(1); + }); +}); + +describe('UnstorageLoaderCache (fs driver)', () => { + let cacheDir: string; + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'sc-loader-cache-')); + }); + + afterEach(async () => { + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('persists entries across separate cache instances on disk', async () => { + const key = sampleKey('persisted'); + const tags = sampleTags('persisted'); + + const writer = new UnstorageLoaderCache(fsDriver({ base: cacheDir }), { revalidate: 300 }); + await writer.set(key, { persisted: true }, 300, tags); + + const reader = new UnstorageLoaderCache(fsDriver({ base: cacheDir }), { revalidate: 300 }); + expect(await reader.get(key)).toEqual({ + kind: 'hit', + value: { persisted: true }, + cacheKey: key, + }); + }); +}); diff --git a/packages/angular/src/server/cache/unstorage-loader-cache.ts b/packages/angular/src/server/cache/unstorage-loader-cache.ts new file mode 100644 index 0000000000..58ce965f6d --- /dev/null +++ b/packages/angular/src/server/cache/unstorage-loader-cache.ts @@ -0,0 +1,196 @@ +import { Storage, createStorage, Driver } from 'unstorage'; +import { + InvalidateInput, + LoaderCache, + LoaderCacheConfig, + LoaderCacheEntry, + LoaderCacheEntryInfo, + LoaderCacheReadResult, +} from '../../loaders/models'; +import { evaluateCacheRead, applyLoaderCacheConfigDefaults } from './utils'; +import { CACHE_KEY_PREFIX } from './cache-key'; +import { GlobalLoaderCacheConfig } from './models'; + +/** Prefix for tag-index keys in unstorage (entries use `sc:loader:…` keys directly). */ +const TAG_INDEX_PREFIX = 'tag:'; + +/** + * Unstorage-backed {@link LoaderCache} for persistent or shared storage. + * Two key spaces share one driver: `{cacheKey}` entries and `tag:{tag}` index arrays. + * Semantics match {@link InMemoryLoaderCache}: `invalidate` marks stale; `get` uses + * {@link evaluateCacheRead} for hit/stale/miss. + * @internal + */ +export class UnstorageLoaderCache implements LoaderCache { + private readonly storage: Storage; + private readonly _config: Required; + + /** + * @param {Driver} driver - Unstorage driver instance from the app (`server.ts`). + * @param {LoaderCacheConfig} [config] - Resolved cache configuration. + */ + constructor(driver: Driver, config: GlobalLoaderCacheConfig = {}) { + this.storage = createStorage({ driver }); + this._config = applyLoaderCacheConfigDefaults(config); + } + + /** @inheritdoc */ + get ttl(): number { + return this._config.revalidate; + } + + /** @inheritdoc */ + get config(): Readonly { + return this._config; + } + + /** @inheritdoc */ + async get(cacheKey: string): Promise { + const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey)); + return evaluateCacheRead(cacheKey, entry ?? null); + } + + /** @inheritdoc */ + async set(cacheKey: string, value: unknown, ttlSeconds: number, tags: string[]): Promise { + const existing = await this.storage.getItem(this.cacheStorageKey(cacheKey)); + if (existing) { + await this.unlinkTags(cacheKey, existing.tags); + } + + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; + const entry: LoaderCacheEntry = { + value, + tags: [...tags], + storedAt: Date.now(), + expiresAt, + stale: false, + }; + await this.storage.setItem(this.cacheStorageKey(cacheKey), entry); + await this.linkTags(cacheKey, tags); + } + + /** @inheritdoc */ + async invalidate(filter: InvalidateInput): Promise { + const tags = filter.tags ?? []; + if (tags.length === 0) { + return 0; + } + const keys = await this.resolveCacheKeysFromTags(tags); + let marked = 0; + for (const cacheKey of keys) { + const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey)); + if (!entry) { + continue; + } + if (!entry.stale) { + await this.storage.setItem(this.cacheStorageKey(cacheKey), { ...entry, stale: true }); + } + marked++; + } + return marked; + } + + /** @inheritdoc */ + async delete(cacheKey: string): Promise { + const entry = await this.storage.getItem(this.cacheStorageKey(cacheKey)); + if (!entry) { + return false; + } + await this.unlinkTags(cacheKey, entry.tags); + await this.storage.removeItem(this.cacheStorageKey(cacheKey)); + return true; + } + + /** @inheritdoc */ + async flush(): Promise { + await this.storage.clear(); + } + + /** @inheritdoc */ + async entries(): Promise { + const keys = await this.storage.getKeys(CACHE_KEY_PREFIX); + const out: LoaderCacheEntryInfo[] = []; + for (const cacheKey of keys) { + const entry = await this.storage.getItem(cacheKey); + if (!entry) { + continue; + } + out.push({ + key: cacheKey, + tags: [...entry.tags], + storedAt: entry.storedAt, + expiresAt: entry.expiresAt, + stale: entry.stale, + }); + } + return out; + } + + /** @inheritdoc */ + enabled(): boolean { + return this._config.enabled; + } + + /** + * Cache entry storage key (OSR-aligned `sc:loader:…`). + * @param {string} cacheKey - Public loader cache key. + * @returns {string} Unstorage key for the entry payload. + */ + private cacheStorageKey(cacheKey: string): string { + return cacheKey; + } + + /** + * Tag index storage key (`tag:{tag}`). + * @param {string} tag - OSR cache tag. + * @returns {string} Unstorage key for the tag index bucket. + */ + private tagStorageKey(tag: string): string { + return `${TAG_INDEX_PREFIX}${tag}`; + } + + /** + * Links a cache key into each tag bucket. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to link. + */ + private async linkTags(cacheKey: string, tags: string[]): Promise { + for (const tag of tags) { + const storageKey = this.tagStorageKey(tag); + const current = (await this.storage.getItem(storageKey)) ?? []; + if (!current.includes(cacheKey)) { + await this.storage.setItem(storageKey, [...current, cacheKey]); + } + } + } + + /** + * Unlinks a cache key from each tag bucket. + * @param {string} cacheKey - Cache entry key. + * @param {string[]} tags - Tags to unlink. + */ + private async unlinkTags(cacheKey: string, tags: string[]): Promise { + for (const tag of tags) { + const storageKey = this.tagStorageKey(tag); + const current = (await this.storage.getItem(storageKey)) ?? []; + const next = current.filter((k) => k !== cacheKey); + if (next.length === 0) { + await this.storage.removeItem(storageKey); + } else { + await this.storage.setItem(storageKey, next); + } + } + } + + /** @param {string[]} tags - Tags to resolve. @returns {Promise>} Matching cache keys. */ + private async resolveCacheKeysFromTags(tags: string[]): Promise> { + const out = new Set(); + for (const tag of tags) { + const keys = (await this.storage.getItem(this.tagStorageKey(tag))) ?? []; + for (const key of keys) { + out.add(key); + } + } + return out; + } +} diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts new file mode 100644 index 0000000000..f2404a7507 --- /dev/null +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -0,0 +1,147 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import { + approxByteSize, + dimensionsFromContext, + resolveConfig, + applyLoaderCacheConfigDefaults, + urlToPathKey, + evaluateCacheRead, + sanitizeSitecoreCacheSegment, + normalizeSitecoreItemIdForCacheKey, + dedupeCacheStrings, +} from './utils'; +import { DEFAULT_CACHE_TTL } from './models'; + +describe('urlToPathKey', () => { + it('sanitizes path segments and uses _ for home', () => { + expect(urlToPathKey('/')).toBe('_'); + expect(urlToPathKey('/About Us')).toBe('about_us'); + expect(urlToPathKey('/products/shoes')).toBe('products/shoes'); + }); + + it('strips locale prefix when provided', () => { + expect(urlToPathKey('/en/about', 'en')).toBe('about'); + }); +}); + +describe('dimensionsFromContext', () => { + it('reads site and locale from route params and derives pathKey', () => { + const dimensions = dimensionsFromContext('page', { + url: '/articles/1?ref=email', + params: { site: 'blog', locale: 'de' }, + query: {}, + }); + + expect(dimensions).toEqual({ + site: 'blog', + locale: 'de', + variantId: 'default', + loaderId: 'page', + pathKey: 'articles/1', + }); + }); + + it('falls back to default site, locale, and home pathKey', () => { + const dimensions = dimensionsFromContext('page', { + url: '', + params: {}, + query: {}, + }); + + expect(dimensions.site).toBe('default'); + expect(dimensions.locale).toBe('en'); + expect(dimensions.pathKey).toBe('_'); + }); +}); + +describe('resolveConfig', () => { + it('strips driver from global cache config', () => { + expect(resolveConfig({ driver: {} as never, revalidate: 60 })).toEqual({ revalidate: 60 }); + }); +}); + +describe('applyLoaderCacheConfigDefaults', () => { + it('applies defaults for every config field', () => { + expect(applyLoaderCacheConfigDefaults({})).toEqual({ + revalidate: DEFAULT_CACHE_TTL, + enabled: true, + defaultSiteName: 'default', + tags: [], + sites: [], + defaultLocale: 'en', + }); + }); +}); + +describe('approxByteSize', () => { + it('returns the JSON string length for serializable values', () => { + expect(approxByteSize({ title: 'Home' })).toBe(JSON.stringify({ title: 'Home' }).length); + }); + + it('returns zero when the value cannot be serialized', () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(approxByteSize(circular)).toBe(0); + }); +}); + +describe('evaluateCacheRead', () => { + it('returns miss when entry is absent', () => { + expect(evaluateCacheRead('sc:key', null)).toEqual({ kind: 'miss', cacheKey: 'sc:key' }); + }); + + it('returns hit for fresh non-stale entries', () => { + const now = 1_000_000; + expect( + evaluateCacheRead( + 'sc:key', + { value: { ok: true }, tags: [], storedAt: now, expiresAt: now + 60_000, stale: false }, + now + ) + ).toEqual({ kind: 'hit', value: { ok: true }, cacheKey: 'sc:key' }); + }); + + it('returns stale when entry is flagged stale or past expiry', () => { + const now = 1_000_000; + expect( + evaluateCacheRead( + 'sc:key', + { + value: { old: true }, + tags: [], + storedAt: now - 120_000, + expiresAt: now - 1, + stale: false, + }, + now + ) + ).toEqual({ kind: 'stale', value: { old: true }, cacheKey: 'sc:key' }); + + expect( + evaluateCacheRead( + 'sc:key', + { value: { flagged: true }, tags: [], storedAt: now, expiresAt: null, stale: true }, + now + ) + ).toEqual({ kind: 'stale', value: { flagged: true }, cacheKey: 'sc:key' }); + }); +}); + +describe('sanitizeSitecoreCacheSegment', () => { + it('lowercases and replaces separators with underscores', () => { + expect(sanitizeSitecoreCacheSegment(' Demo/Site ')).toBe('demo_site'); + }); +}); + +describe('normalizeSitecoreItemIdForCacheKey', () => { + it('strips braces and lowercases item ids', () => { + expect(normalizeSitecoreItemIdForCacheKey(' {ABC-123} ')).toBe('abc-123'); + }); +}); + +describe('dedupeCacheStrings', () => { + it('preserves first-seen order while removing duplicates', () => { + expect(dedupeCacheStrings(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts new file mode 100644 index 0000000000..20c04248dd --- /dev/null +++ b/packages/angular/src/server/cache/utils.ts @@ -0,0 +1,170 @@ +import { + LoaderCacheConfig, + LoaderCacheReadResult, + LoaderCacheEntry, + LoaderContext, +} from '../../loaders/models'; +import { GlobalLoaderCacheConfig, CacheKeyDimensions, DEFAULT_CACHE_TTL } from './models'; + +/** + * Approximate serialized byte size of a cache value (demo/admin helper). + * Only used for demo purposes. Remove before release. + * TODO: Remove before release. + * @param {unknown} value - Value to measure. + * @returns {number} JSON string length, or `0` when serialization fails. + */ +export function approxByteSize(value: unknown): number { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} + +/** + * Removes the query string from a URL path. + * @param {string} url - URL or path that may include `?query`. + * @returns {string} Pathname without query string. + * @internal + */ +function stripQuery(url: string): string { + const i = url.indexOf('?'); + return i === -1 ? url : url.slice(0, i); +} + +/** + * Converts a loader URL to the `pathKey` segment used in OSR-aligned cache keys. + * Strips query strings, trims slashes, sanitizes segments, and removes a leading + * locale prefix when it matches `params.locale`. Home resolves to `'_'`. + * @param {string} url - Loader URL (may include query string). + * @param {string} [locale] - Optional locale used to strip a leading `/locale` prefix. + * @returns {string} Sanitized path key (`'_'` for home). + * @example + * ```ts + * urlToPathKey('/'); // '_' + * urlToPathKey('/About Us'); // 'about_us' + * urlToPathKey('/en/about', 'en'); // 'about' + * ``` + * @internal + */ +export function urlToPathKey(url: string, locale?: string): string { + const pathname = stripQuery(url || '/').replace(/^\/+|\/+$/g, ''); + let segments = pathname ? pathname.split('/').filter(Boolean) : []; + if (locale && segments[0]?.toLowerCase() === locale.toLowerCase()) { + segments = segments.slice(1); + } + if (segments.length === 0) { + return '_'; + } + return segments.map((segment) => sanitizeSitecoreCacheSegment(segment)).join('/'); +} + +/** + * Derives {@link CacheKeyDimensions} from a loader context. + * Used by {@link buildCacheKey} and admin tooling. + * @param {string} loaderId - Loader id being resolved. + * @param {LoaderContext} ctx - Loader context (URL + route params). + * @returns {CacheKeyDimensions} Parsed cache key dimensions. + * @internal + */ +export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { + const params = (ctx.params ?? {}) as Record; + const site = (params?.site as string) || 'default'; + const locale = (params?.locale as string) || 'en'; + const pathKey = urlToPathKey(ctx.url || '/', locale); + + return { + site, + locale, + variantId: 'default', + loaderId, + pathKey, + }; +} + +/** + * Strips `driver` from {@link GlobalLoaderCacheConfig} before passing config to backends. + * @param {GlobalLoaderCacheConfig} config - Global cache config from {@link createLoaderCache}. + * @returns {LoaderCacheConfig} Backend-safe config without the unstorage driver instance. + * @internal + */ +export function resolveConfig(config: GlobalLoaderCacheConfig): LoaderCacheConfig { + const clonedConfig = { ...config }; + delete clonedConfig.driver; + return clonedConfig; +} + +/** + * Applies defaults for every {@link LoaderCacheConfig} field. + * @param {LoaderCacheConfig} [config] - Partial config from `createLoaderCache()` or a backend constructor. + * @returns {Required} Fully populated config used by cache backends. + * @internal + */ +export function applyLoaderCacheConfigDefaults( + config: LoaderCacheConfig = {} +): Required { + return { + revalidate: config.revalidate ?? DEFAULT_CACHE_TTL, + enabled: config.enabled ?? true, + defaultSiteName: config.defaultSiteName ?? 'default', + tags: config.tags ?? [], + sites: config.sites ?? [], + defaultLocale: config.defaultLocale ?? 'en', + }; +} + +/** + * Maps a stored entry to the three-outcome read result used by {@link ServerLoaderDataProvider} (Phase 3 SWR). + * @param {string} cacheKey - Key being read. + * @param {LoaderCacheEntry | null | undefined} entry - Stored entry, if any. + * @param {number} [now] - Current timestamp for TTL comparison (defaults to `Date.now()`). + * @returns {LoaderCacheReadResult} Hit, stale, or miss classification. + * @internal + */ +export function evaluateCacheRead( + cacheKey: string, + entry: LoaderCacheEntry | null | undefined, + now = Date.now() +): LoaderCacheReadResult { + if (!entry) { + return { kind: 'miss', cacheKey }; + } + if (entry.stale || (entry.expiresAt !== null && entry.expiresAt <= now)) { + return { kind: 'stale', value: entry.value, cacheKey }; + } + return { kind: 'hit', value: entry.value, cacheKey }; +} + +/** + * Sanitizes a segment for Sitecore cache keys and tags (lowercase, separators → `_`). + * @param {string} value - Raw segment from site, locale, path, or loader id. + * @returns {string} Sanitized segment safe for keys and tags. + * @internal + */ +export function sanitizeSitecoreCacheSegment(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[/:\s]+/g, '_'); +} + +/** + * Normalizes a Sitecore item GUID for cache keys/tags (lowercase, no braces). + * @param {string} itemId - Raw Sitecore item id or GUID. + * @returns {string} Normalized id segment. + * @internal + */ +export function normalizeSitecoreItemIdForCacheKey(itemId: string): string { + return itemId.trim().toLowerCase().replace(/[{}]/g, ''); +} + +/** + * Deduplicates strings while preserving first-seen order. + * @param {string[]} values - Tag or key candidates. + * @returns {string[]} Deduplicated list. + * @internal + */ +export function dedupeCacheStrings(values: string[]): string[] { + const dedupedSet = new Set(values); + return Array.from(dedupedSet); +} diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index 7a7d8e5937..d09ef94f90 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -11,4 +11,12 @@ export { DataHandlerConfig, } from './models'; -export { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; +export { ServerLoaderRunner } from './server-loader-runner'; +export { provideServerLoaderRunner } from './provide-server-loader-runner'; + +export * from './middleware'; + +// Loader cache (server-only). Browser code must not reach createLoaderCache — +// see plan §1 (Browser safety). The exports here are types + server factories; +// they tree-shake out of the browser bundle when not referenced. +export * from './cache'; diff --git a/packages/angular/src/server/middleware/index.ts b/packages/angular/src/server/middleware/index.ts new file mode 100644 index 0000000000..97c8cc01f9 --- /dev/null +++ b/packages/angular/src/server/middleware/index.ts @@ -0,0 +1,16 @@ +export { + createLoaderDataServiceMiddleware, + createExpressDataMiddleware, +} from './loader-data-service-middleware'; +export { + createSitecoreRevalidateMiddleware, + type SitecoreRevalidateMiddlewareOptions, + resolveConfiguredRevalidateSecret, +} from './sitecore-revalidate-middleware'; +export { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + extractSitecoreEdgeContentId, + type SitecoreEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateUpdate, + type CollectSitecoreTagsFromEdgeBodyOptions, +} from './sitecore-edge-webhook-revalidation'; diff --git a/packages/angular/src/server/loader-data-service-middleware.spec.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts similarity index 79% rename from packages/angular/src/server/loader-data-service-middleware.spec.ts rename to packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts index f9a92f4758..5e4fb5f32d 100644 --- a/packages/angular/src/server/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts @@ -1,12 +1,13 @@ /* eslint-disable jsdoc/require-jsdoc */ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { LoaderFn } from '../loaders/models'; -import { NotFoundNavigationError, LoaderHttpError } from '../loaders/models'; +import type { LoaderFn } from '../../loaders/models'; +import { NotFoundNavigationError, LoaderHttpError } from '../../loaders/models'; import { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; -import { LOADER_DATA_ENDPOINT } from './constants'; -import { EXTRACT_REQUEST_CONTEXT_TOKEN } from './models'; -import type { LoaderRegistry } from './models'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { EXTRACT_REQUEST_CONTEXT_TOKEN } from '../models'; +import type { LoaderRegistry } from '../../loaders/loader-registry.token'; +import { createLoaderCache } from '../cache/loader-cache'; /** * Minimal Express `res` stub for middleware tests. @@ -37,12 +38,12 @@ describe('createLoaderDataServiceMiddleware', () => { }); }); - /** - * @param {{ loaders: import('./models').LoaderRegistry; endpoint?: string }} opts - Middleware factory options - * @param {import('./models').LoaderRegistry} opts.loaders - Registered route loaders - * @param {string} [opts.endpoint] - Data endpoint path override - */ - function createMiddleware(opts: { loaders: LoaderRegistry; endpoint?: string }) { + /** eslint-disable-next-line jsdoc/require-jsdoc */ + function createMiddleware(opts: { + loaders: LoaderRegistry; + endpoint?: string; + cache?: import('../../loaders/models').LoaderCache; + }) { const extractReq = TestBed.inject(EXTRACT_REQUEST_CONTEXT_TOKEN); return createLoaderDataServiceMiddleware({ ...opts, @@ -288,6 +289,56 @@ describe('createLoaderDataServiceMiddleware', () => { expect(res.json).not.toHaveBeenCalled(); }); + it('should serve cached loader data on repeat requests without re-running the loader', async () => { + const mockLoader = vi.fn().mockResolvedValue({ title: 'Cached page' }) as LoaderFn; + const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); + const middleware = createMiddleware({ + loaders: { page: mockLoader }, + endpoint, + cache, + }); + const req = { + method: 'POST', + path: endpoint, + body: { + loaderId: 'page', + url: '/cached-page', + params: { site: 'demo', locale: 'en' }, + query: {}, + }, + query: {}, + headers: {}, + }; + const res1 = createMockRes(); + const res2 = createMockRes(); + + await middleware(req as any, res1 as any, createMockNext()); + await middleware(req as any, res2 as any, createMockNext()); + + expect(mockLoader).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + 'sc:loader:page:demo:en:default:cached-page', + { title: 'Cached page' }, + 300, + expect.arrayContaining([ + 'sc:loader:page:demo:en:default:cached-page', + 'sc:site:demo', + 'sc:locale:en', + ]) + ); + expect(res1.json).toHaveBeenCalledWith({ + kind: 'data', + data: { title: 'Cached page' }, + }); + expect(res2.json).toHaveBeenCalledWith({ + kind: 'data', + data: { title: 'Cached page' }, + }); + setSpy.mockRestore(); + }); + it('should return 400 when POST body missing loaderId', async () => { const middleware = createMiddleware({ loaders: { page: vi.fn() as LoaderFn }, diff --git a/packages/angular/src/server/loader-data-service-middleware.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.ts similarity index 59% rename from packages/angular/src/server/loader-data-service-middleware.ts rename to packages/angular/src/server/middleware/loader-data-service-middleware.ts index 794789587f..e341602d51 100644 --- a/packages/angular/src/server/loader-data-service-middleware.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.ts @@ -1,89 +1,49 @@ import { LoaderApiRequest, LoaderApiResponse, - LoaderContext, - isLoaderRedirectResult, NotFoundNavigationError, - RequestContext, LoaderHttpError, -} from '../loaders/models'; -import { extractRequestContext } from '../loaders/utils'; + LoaderDataResult, +} from '../../loaders/models'; +import { extractRequestContext } from '../../loaders/utils'; import { ExpressDataHandlerOptions, ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse, - LoaderRegistry, -} from './models'; -import { LOADER_DATA_ENDPOINT } from './constants'; +} from '../models'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { ServerLoaderRunner } from '../server-loader-runner'; /** - * Execute a loader and return the API response - * @param {LoaderApiRequest} request - The loader data request - * @param {LoaderRegistry} loaders - The loader registry - * @param {RequestContext} [requestContext] - The request context - * @returns {Promise} Promise resolving to the API response + * Map loader resolution result to wire-level API response. + * @param {LoaderDataResult} result - Loader result from the shared registry + * @returns {LoaderApiResponse} Wire envelope for the client */ -async function executeLoader( - request: LoaderApiRequest, - loaders: LoaderRegistry, - requestContext?: RequestContext -): Promise { - const { loaderId, url, params, query } = request; - - const loader = loaders[loaderId]; - if (!loader) { +function toApiResponse(result: LoaderDataResult): LoaderApiResponse { + if (result.kind === 'redirect') { return { - kind: 'error', - status: 500, - message: `No loader registered for id "${loaderId}"`, + kind: 'redirect', + redirect: { + loaderRedirectTarget: result.redirect.loaderRedirectTarget, + status: result.redirect.status, + }, }; } - const context: LoaderContext = { - url, - params, - query, - requestContext, - }; - - try { - const result = await loader(context); - if (isLoaderRedirectResult(result)) { - return { - kind: 'redirect', - redirect: { - loaderRedirectTarget: result.loaderRedirectTarget, - status: result.status, - }, - }; - } - return { - kind: 'data', - data: result, - }; - } catch (error) { - if (error instanceof NotFoundNavigationError) { - return { - kind: 'notFound', - status: 404, - }; + if (result.kind === 'error') { + const cause = result.cause; + if (cause instanceof NotFoundNavigationError) { + return { kind: 'notFound', status: 404 }; } - if (error instanceof LoaderHttpError) { - return { - kind: 'error', - status: error.status, - message: error.message, - }; + if (cause instanceof LoaderHttpError) { + return { kind: 'error', status: cause.status, message: cause.message }; } - const message = error instanceof Error ? error.message : 'Loader failed'; - return { - kind: 'error', - status: 500, - message, - }; + return { kind: 'error', status: result.status, message: result.message }; } + + return { kind: 'data', data: result.data }; } /** @@ -137,23 +97,21 @@ function parseLoaderRequest( * ```typescript * import { createExpressDataMiddleware, LOADER_DATA_ENDPOINT } from '@sitecore-content-sdk/angular'; * - * // Use default endpoint (same as client when FETCH_DATA_ENDPOINT is not provided) - * app.use(createExpressDataMiddleware({ loaders: SERVER_LOADERS })); + * // Pass the same LOADERS object used with provideLoaderRegistry(LOADERS) + * app.use(createExpressDataMiddleware({ loaders: LOADERS })); * * // Or pass the same endpoint you provide to the Angular app (FETCH_DATA_ENDPOINT) * const dataEndpoint = process.env.DATA_ENDPOINT ?? LOADER_DATA_ENDPOINT; - * app.use(createExpressDataMiddleware({ loaders: SERVER_LOADERS, endpoint: dataEndpoint })); + * app.use(createExpressDataMiddleware({ loaders: LOADERS, endpoint: dataEndpoint })); * ``` * @public */ export function createLoaderDataServiceMiddleware( options: ExpressDataHandlerOptions ): ExpressMiddleware { - const { - loaders, - endpoint = LOADER_DATA_ENDPOINT, - extractRequestContext: extractReq = extractRequestContext, - } = options; + const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options; + const serverLoaderData = new ServerLoaderRunner(loaders, cache); + return async ( req: ExpressRequest, res: ExpressResponse, @@ -163,11 +121,15 @@ export function createLoaderDataServiceMiddleware( next(); return; } - const requestContext = extractReq(req); try { const parsed = parseLoaderRequest(req); if ('loaderId' in parsed) { - const result = await executeLoader(parsed, loaders, requestContext); + // Per refactor plan A2: extract once at the boundary; ride on the payload. + // POST body's `angularRequestContext` is ignored — server-derived data + // (hostname, headers) must come from the actual request, not from a + // payload the browser could spoof. + parsed.angularRequestContext = extractRequestContext(req); + const result = toApiResponse(await serverLoaderData.resolve(parsed)); sendResponse(res, result); } else { res diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts new file mode 100644 index 0000000000..a6bac77af0 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.spec.ts @@ -0,0 +1,52 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect } from 'vitest'; +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + extractSitecoreEdgeContentId, +} from './sitecore-edge-webhook-revalidation'; + +describe('sitecore-edge-webhook-revalidation', () => { + describe('extractSitecoreEdgeContentId', () => { + it('strips -media and -layout suffixes', () => { + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-media')).toBe( + '71B0BA0716214254AEE4429B1A970C8B' + ); + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-LAYOUT')).toBe( + '71B0BA0716214254AEE4429B1A970C8B' + ); + }); + }); + + describe('collectSitecoreTagsFromEdgeRevalidateRequestBody', () => { + it('maps updates to sc:item tags using entity_culture', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + updates: [ + { + identifier: '71B0BA0716214254AEE4429B1A970C8B-media', + entity_culture: 'en', + }, + ], + }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + + it('passes through full sc: tags in tags array', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { tags: ['sc:loader:dictionary:default:en'] }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:loader:dictionary:default:en']); + }); + + it('maps bare ids in tags array to item tags with defaultLocale', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { tags: ['71B0BA0716214254AEE4429B1A970C8B'] }, + { defaultLocale: 'en' } + ); + expect(tags).toEqual(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + }); +}); diff --git a/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts new file mode 100644 index 0000000000..1f4f7d2d97 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-edge-webhook-revalidation.ts @@ -0,0 +1,94 @@ +import { buildSitecoreItemCacheTag, SITECORE_CONTENT_CACHE_TAG_PREFIX } from '../cache/cache-tags'; +import { dedupeCacheStrings } from '../cache/utils'; + +/** + * One content change entry as commonly seen in Experience Edge / Content Operations payloads. + * @public + */ +export type SitecoreEdgeRevalidateUpdate = { + identifier?: string; + entity_definition?: string; + operation?: string; + entity_culture?: string; +}; + +/** + * Request body shape for webhook-driven revalidation. + * @public + */ +export type SitecoreEdgeRevalidateRequestBody = { + invocation_id?: string; + updates?: SitecoreEdgeRevalidateUpdate[]; + continues?: boolean; + tags?: string[]; +}; + +/** + * Strips Experience Edge style suffixes from an `identifier`. + * @param {string} identifier - Raw identifier from a webhook update row. + * @public + */ +export function extractSitecoreEdgeContentId(identifier: string): string { + if (!identifier || typeof identifier !== 'string') { + return ''; + } + const trimmed = identifier.trim(); + return trimmed.replace(/-(?:media|layout)$/i, ''); +} + +const FULL_TAG_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:`; + +/** + * Options for {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. + * @public + */ +export type CollectSitecoreTagsFromEdgeBodyOptions = { + defaultLocale: string; +}; + +/** + * Maps an Experience Edge webhook JSON body to Sitecore cache tag strings. + * + * Accepts fully qualified `sc:…` tags in `body.tags`, raw content identifiers + * (with optional `-media`/`-layout` suffixes), and `updates[]` rows with + * `identifier` + `entity_culture`. + * @param {SitecoreEdgeRevalidateRequestBody | null | undefined} body - Parsed webhook JSON body. + * @param {CollectSitecoreTagsFromEdgeBodyOptions} options - Locale fallback when an update omits `entity_culture`. + * @returns {string[]} Deduplicated Sitecore cache tags ready for {@link LoaderCache.invalidate}. + * @public + */ +export function collectSitecoreTagsFromEdgeRevalidateRequestBody( + body: SitecoreEdgeRevalidateRequestBody | null | undefined, + options: CollectSitecoreTagsFromEdgeBodyOptions +): string[] { + const { defaultLocale } = options; + const out: string[] = []; + + for (const tag of body?.tags ?? []) { + if (typeof tag !== 'string') { + continue; + } + if (!tag) { + continue; + } + if (tag.startsWith(FULL_TAG_PREFIX)) { + out.push(tag); + } else { + const id = extractSitecoreEdgeContentId(tag); + if (id) { + out.push(buildSitecoreItemCacheTag({ itemId: id, locale: defaultLocale })); + } + } + } + + for (const u of body?.updates ?? []) { + const id = extractSitecoreEdgeContentId(u?.identifier ?? ''); + if (!id) { + continue; + } + const locale = u?.entity_culture?.trim() || defaultLocale; + out.push(buildSitecoreItemCacheTag({ itemId: id, locale })); + } + + return dedupeCacheStrings(out).filter(Boolean); +} diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts new file mode 100644 index 0000000000..e32d4d8041 --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts @@ -0,0 +1,241 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createSitecoreRevalidateMiddleware } from './sitecore-revalidate-middleware'; +import { createLoaderCache } from '../cache/loader-cache'; +import { buildCacheKey } from '../cache/cache-key'; +import { buildLoaderCacheTags } from '../cache/cache-tags'; +import type { ExpressRequest, ExpressResponse } from '../models'; + +function createMockRes() { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { + status: ReturnType; + json: ReturnType; + }; +} + +describe('createSitecoreRevalidateMiddleware', () => { + let cache: ReturnType; + let cacheKey: string; + const next = vi.fn(); + + beforeEach(async () => { + delete process.env.SITECORE_REVALIDATE_SECRET; + next.mockClear(); + cache = createLoaderCache({ revalidate: 300 }); + const built = buildCacheKey('page', { + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + cacheKey = built.key; + await cache.set( + cacheKey, + { title: 'About' }, + 300, + buildLoaderCacheTags('page', built.dimensions, cacheKey, { + layout: { sitecore: { route: { itemId: '71B0BA0716214254AEE4429B1A970C8B' } } }, + locale: 'en', + mode: {}, + }) + ); + }); + + afterEach(() => { + delete process.env.SITECORE_REVALIDATE_SECRET; + }); + + it('marks entries stale on item publish webhook', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { + updates: [{ identifier: '71B0BA0716214254AEE4429B1A970C8B', entity_culture: 'en' }], + }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect((await cache.get(cacheKey)).kind).toBe('stale'); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + revalidated: true, + tagsCount: expect.any(Number), + marked: expect.any(Number), + invocation_id: null, + continues: false, + durationMs: expect.any(Number), + }) + ); + const body = (res.json as ReturnType).mock.calls[0][0]; + expect(body.tagsCount).toBeGreaterThan(0); + expect(body.marked).toBeGreaterThan(0); + expect(body.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('returns 401 when secret is configured but header mismatches', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { tags: ['sc:site:demo'] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('falls through non-POST requests', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'GET', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: {}, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(next).toHaveBeenCalled(); + }); + + it('returns 400 when request body is not a JSON object', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: ['not', 'an', 'object'], + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Request body must be a JSON object.' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 400 when resolved tags are empty', async () => { + const middleware = createSitecoreRevalidateMiddleware({ cache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { updates: [] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: + 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.', + }); + }); + + it('marks dictionary loader entries stale via sites fan-out even without webhook tags', async () => { + const dictBuilt = buildCacheKey('dictionary', { + url: '/', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + const dictKey = dictBuilt.key; + await cache.set(dictKey, { hello: 'world' }, 300, buildLoaderCacheTags('dictionary', dictBuilt.dimensions, dictKey)); + + const middleware = createSitecoreRevalidateMiddleware({ + cache, + defaultLocale: 'en', + sites: [{ name: 'demo', hostName: '*', language: 'en' }], + }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { invocation_id: 'dict-fanout', continues: true }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect((await cache.get(dictKey)).kind).toBe('stale'); + expect(res.json).toHaveBeenCalledWith({ + revalidated: true, + tagsCount: 1, + marked: 1, + invocation_id: 'dict-fanout', + continues: true, + durationMs: expect.any(Number), + }); + }); + + it('returns 500 when cache.invalidate throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failingCache = { + ...cache, + invalidate: vi.fn().mockRejectedValue(new Error('invalidate failed')), + }; + const middleware = createSitecoreRevalidateMiddleware({ cache: failingCache, defaultLocale: 'en' }); + const res = createMockRes(); + + await middleware( + { + method: 'POST', + path: '/api/revalidate', + url: '/api/revalidate', + headers: {}, + body: { tags: ['sc:site:demo'] }, + query: {}, + } as ExpressRequest, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error.' }); + errorSpy.mockRestore(); + }); +}); diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts new file mode 100644 index 0000000000..c3d7faad4b --- /dev/null +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -0,0 +1,133 @@ +import type { SiteInfo } from '@sitecore-content-sdk/content/site'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { LoaderCache } from '../../loaders/models'; +import { buildLoaderDictionaryCacheTagsFromSites } from '../cache/cache-tags'; +import { dedupeCacheStrings } from '../cache/utils'; +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateRequestBody, +} from './sitecore-edge-webhook-revalidation'; +import { readProcessEnv } from '../utils'; +const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; +const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; +const DEFAULT_ENDPOINT = '/api/revalidate'; + +/** + * Returns a non-empty trimmed secret, or `undefined` when unset or whitespace-only. + * @param {string | undefined} secretOption - Explicit secret from handler options. + * @param {string | undefined} envValue - Secret from `process.env` (e.g. `SITECORE_REVALIDATE_SECRET`). + * @returns {string | undefined} The resolved secret + * @internal + */ +export function resolveConfiguredRevalidateSecret( + secretOption: string | undefined, + envValue: string | undefined +): string | undefined { + const raw = secretOption !== undefined ? secretOption : envValue; + const trimmed = raw?.trim(); + return trimmed || undefined; +} + +/** + * Options for {@link createSitecoreRevalidateMiddleware}. + * @public + */ +export interface SitecoreRevalidateMiddlewareOptions { + /** Shared cache instance from {@link createLoaderCache}. */ + cache: LoaderCache; + /** Default: `process.env.SITECORE_REVALIDATE_SECRET` */ + secret?: string; + /** Locale fallback when an update has no `entity_culture`; default `'en'`. */ + defaultLocale?: string; + /** + * When set, every webhook also marks stale one + * `sc:loader:dictionary::` entry per site (dictionary fan-out). + */ + sites?: SiteInfo[]; + /** Endpoint path; default `/api/revalidate`. */ + endpoint?: string; +} + +/** + * Express middleware aligned with Next.js `createSitecoreRevalidateRouteHandler`. + * + * Handles `POST /api/revalidate` (configurable via `endpoint`): + * - Authenticates with `SITECORE_REVALIDATE_SECRET` / `x-revalidate-secret` when configured. + * - Parses Experience Edge webhook bodies via {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. + * - Optionally appends dictionary loader tags for each configured site. + * - Calls {@link LoaderCache.invalidate} (marks entries stale; does not delete). + * + * Response shape: `{ revalidated, tagsCount, marked, invocation_id, continues, durationMs }`. + * @param {SitecoreRevalidateMiddlewareOptions} options - The options for the middleware + * @returns {ExpressMiddleware} The middleware function + * @public + */ +export function createSitecoreRevalidateMiddleware( + options: SitecoreRevalidateMiddlewareOptions +): ExpressMiddleware { + const { cache, secret, defaultLocale = 'en', sites, endpoint = DEFAULT_ENDPOINT } = options; + + const dictionaryTags = + sites !== undefined + ? buildLoaderDictionaryCacheTagsFromSites({ sites, baseLocale: defaultLocale }) + : []; + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + if (req.method !== 'POST' || req.path !== endpoint) { + next(); + return; + } + + const startTimestamp = Date.now(); + + try { + const configuredSecret = resolveConfiguredRevalidateSecret( + secret, + readProcessEnv(DEFAULT_SECRET_ENV_VAR) + ); + + const headers = req.headers as Record; + const providedSecret = headers[DEFAULT_SECRET_HEADER]; + const headerValue = Array.isArray(providedSecret) ? providedSecret[0] : providedSecret; + if (headerValue !== configuredSecret) { + res.status(401).json({ error: 'Unauthorized.' }); + return; + } + + const body = req.body; + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + res.status(400).json({ error: 'Request body must be a JSON object.' }); + return; + } + + const webhookBody = body as SitecoreEdgeRevalidateRequestBody; + + const tags = dedupeCacheStrings([ + ...collectSitecoreTagsFromEdgeRevalidateRequestBody(webhookBody, { defaultLocale }), + ...dictionaryTags, + ]); + + if (tags.length === 0) { + res.status(400).json({ + error: + 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.', + }); + return; + } + + const marked = await cache.invalidate({ tags }); + + res.status(200).json({ + revalidated: true, + tagsCount: tags.length, + marked, + invocation_id: webhookBody.invocation_id ?? null, + continues: webhookBody.continues ?? false, + durationMs: Date.now() - startTimestamp, + }); + } catch (error) { + console.error('Sitecore revalidate middleware failed:', error); + res.status(500).json({ error: 'Internal Server Error.' }); + } + }; +} diff --git a/packages/angular/src/server/models.ts b/packages/angular/src/server/models.ts index 904a5c98de..4e534646ad 100644 --- a/packages/angular/src/server/models.ts +++ b/packages/angular/src/server/models.ts @@ -1,6 +1,7 @@ import { InjectionToken } from '@angular/core'; import type { RequestContext } from '../loaders/models'; -import type { LoaderFn } from '../loaders/models'; +import type { LoaderRegistry } from '../loaders/loader-registry.token'; +import type { LoaderCache } from '../loaders/models'; /** * Injection token for the request context extractor (used by tests to provide a mock via TestBed). @@ -68,10 +69,9 @@ export type ExpressMiddleware = ( ) => void | Promise; /** - * Loader registry type - maps loader IDs to loader functions * @public */ -export type LoaderRegistry = Record; +export type { LoaderRegistry } from '../loaders/loader-registry.token'; /** * Options for the Express data handler @@ -79,9 +79,14 @@ export type LoaderRegistry = Record; */ export interface ExpressDataHandlerOptions extends DataHandlerConfig { /** - * The loader registry containing all registered loaders + * The shared loader registry (same object as {@link provideLoaderRegistry}). */ loaders: LoaderRegistry; + /** + * Optional loader cache. When supplied, /_data responses go through + * cache-aside; omit to run loaders directly on every request. + */ + cache?: LoaderCache; /** * Optional request context extractor (e.g. for testing via TestBed). * If not provided, uses the default implementation from loaders/utils. diff --git a/packages/angular/src/server/provide-server-loader-runner.ts b/packages/angular/src/server/provide-server-loader-runner.ts new file mode 100644 index 0000000000..7be5c2c464 --- /dev/null +++ b/packages/angular/src/server/provide-server-loader-runner.ts @@ -0,0 +1,37 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + REQUEST_CONTEXT, +} from '@angular/core'; +import { LOADER_REGISTRY } from '../loaders/loader-registry.token'; +import { SERVER_LOADER_RUNNER } from '../loaders/server-loader-runner.token'; +import { LoaderCache, LoaderApiRequest } from '../loaders/models'; +import { ServerLoaderRunner } from './server-loader-runner'; + +/** + * Wires SSR {@link SERVER_LOADER_DATA_PROVIDER} to {@link ServerLoaderRunner} + * using the shared {@link LOADER_REGISTRY}. Include in server application providers + * alongside {@link provideLoaderRegistry}. + * @returns Environment providers for SSR loader data resolution + * @public + */ +export function provideServerLoaderRunner(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: SERVER_LOADER_RUNNER, + useFactory: () => { + const registry = inject(LOADER_REGISTRY); + return { + resolve(request: LoaderApiRequest) { + const ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as + | { cache?: LoaderCache } + | undefined; + const cache = ssrContext?.cache; + return new ServerLoaderRunner(registry, cache).resolve(request); + }, + }; + }, + }, + ]); +} diff --git a/packages/angular/src/server/server-loader-runner.spec.ts b/packages/angular/src/server/server-loader-runner.spec.ts new file mode 100644 index 0000000000..e4861b8202 --- /dev/null +++ b/packages/angular/src/server/server-loader-runner.spec.ts @@ -0,0 +1,353 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerLoaderRunner } from './server-loader-runner'; +import type { LoaderCache, LoaderFn } from '../loaders/models'; +import { createLoaderCache } from './cache/loader-cache'; +import { buildCacheKey } from './cache/cache-key'; + +describe('ServerLoaderRunner', () => { + const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); + + beforeEach(async () => { + vi.mocked(pageLoader).mockClear(); + vi.mocked(pageLoader).mockResolvedValue({ title: 'Page' }); + }); + + it('should return error when loader id is not in registry', async () => { + const provider = new ServerLoaderRunner({}); + const result = await provider.resolve({ + loaderId: 'missing', + url: '/path', + params: {}, + query: {}, + }); + expect(result).toEqual({ + kind: 'error', + status: 500, + message: 'No loader registered for id "missing"', + }); + }); + + it('should invoke loader and return data on cache miss', async () => { + const provider = new ServerLoaderRunner({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/about', + params: { slug: 'about' }, + query: { q: '1' }, + }); + + expect(pageLoader).toHaveBeenCalledWith({ + url: '/about', + params: { slug: 'about' }, + query: { q: '1' }, + requestContext: undefined, + }); + expect(result).toEqual({ kind: 'data', data: { title: 'Page' } }); + }); + + it('should return cached data without invoking loader', async () => { + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ kind: 'hit', value: { cached: true }, cacheKey: 'k' }), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + ttl: 300, + enabled: vi.fn().mockReturnValue(true), + config: {}, + }; + + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/cached', + params: {}, + query: {}, + }); + + expect(result).toEqual({ kind: 'data', data: { cached: true } }); + expect(pageLoader).not.toHaveBeenCalled(); + }); + + it('should return redirect when loader returns redirect result', async () => { + vi.mocked(pageLoader).mockResolvedValueOnce({ + loaderRedirectTarget: '/other', + status: 302, + }); + const provider = new ServerLoaderRunner({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/redirect', + params: {}, + query: {}, + }); + + expect(result).toEqual({ + kind: 'redirect', + redirect: { loaderRedirectTarget: '/other', status: 302 }, + }); + }); + + it('should return error with cause when loader throws', async () => { + const err = new Error('Loader failed'); + vi.mocked(pageLoader).mockRejectedValueOnce(err); + const provider = new ServerLoaderRunner({ page: pageLoader }); + const result = await provider.resolve({ + loaderId: 'page', + url: '/fail', + params: {}, + query: {}, + }); + + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.message).toBe('Loader failed'); + expect(result.cause).toBe(err); + } + }); + + it('should store loader result in cache when cacheable', async () => { + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ kind: 'miss', cacheKey: 'k' }), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + ttl: 300, + enabled: vi.fn().mockReturnValue(true), + config: {}, + }; + + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + await provider.resolve({ + loaderId: 'page', + url: '/store', + params: {}, + query: {}, + }); + + expect(cache.set).toHaveBeenCalled(); + }); + + it('should skip the cache when it is globally disabled and the route did not opt in', async () => { + const cache: LoaderCache = { + get: vi.fn(), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + ttl: 300, + enabled: vi.fn().mockReturnValue(false), + config: {}, + }; + + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + await provider.resolve({ + loaderId: 'page', + url: '/live', + params: {}, + query: {}, + }); + await provider.resolve({ + loaderId: 'page', + url: '/live', + params: {}, + query: {}, + }); + + expect(pageLoader).toHaveBeenCalledTimes(2); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should use the cache for a route that opts in even when global caching is disabled', async () => { + const cache = createLoaderCache({ enabled: false, revalidate: 300 }); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/featured', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, tags: ['featured'], revalidate: 60 }, + }; + + await provider.resolve(request); + await provider.resolve(request); + + expect(pageLoader).toHaveBeenCalledTimes(1); + }); + + it('should pass cacheOptions.revalidate as TTL to cache.set', async () => { + const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/ttl-override', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, revalidate: 60 }, + }; + + await provider.resolve(request); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + expect.any(String), + { title: 'Page' }, + 60, + expect.any(Array) + ); + setSpy.mockRestore(); + }); + + it('should merge cacheOptions.tags into tags passed to cache.set', async () => { + const cache = createLoaderCache({ revalidate: 300 }); + const setSpy = vi.spyOn(cache, 'set'); + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const request = { + loaderId: 'page', + url: '/tagged', + params: { site: 'demo', locale: 'en' }, + query: {}, + cacheOptions: { enabled: true, tags: ['featured', 'campaign-x'] }, + }; + + await provider.resolve(request); + + expect(setSpy).toHaveBeenCalledTimes(1); + const tags = setSpy.mock.calls[0][3] as string[]; + expect(tags).toContain('featured'); + expect(tags).toContain('campaign-x'); + expect(tags).toContain('sc:site:demo'); + setSpy.mockRestore(); + }); + + it('should not cache redirect responses', async () => { + vi.mocked(pageLoader).mockResolvedValueOnce({ + loaderRedirectTarget: '/login', + status: 302, + }); + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ kind: 'miss', cacheKey: 'k' }), + set: vi.fn(), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + ttl: 300, + enabled: vi.fn().mockReturnValue(true), + config: {}, + }; + + const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/protected', + params: {}, + query: {}, + }); + + expect(result.kind).toBe('redirect'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should serve stale data immediately and refresh in the background', async () => { + let version = 1; + const loader = vi.fn(async () => ({ title: `v${version++}` })); + const cache = createLoaderCache({ revalidate: 300 }); + const provider = new ServerLoaderRunner({ page: loader }, cache); + const request = { + loaderId: 'page', + url: '/about', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + + await provider.resolve(request); + const { key } = buildCacheKey('page', { + url: request.url, + params: request.params, + query: request.query, + }); + await cache.invalidate({ tags: [key] }); + + const staleResult = await provider.resolve(request); + expect(staleResult).toEqual({ kind: 'data', data: { title: 'v1' } }); + + await vi.waitFor(async () => { + expect(await cache.get(key)).toEqual( + expect.objectContaining({ kind: 'hit', value: { title: 'v2' } }) + ); + }); + expect(loader).toHaveBeenCalledTimes(2); + + const freshResult = await provider.resolve(request); + expect(freshResult).toEqual({ kind: 'data', data: { title: 'v2' } }); + }); + + it('should coalesce concurrent stale-while-revalidate refreshes', async () => { + let version = 1; + const loader = vi.fn(async () => ({ title: `v${version++}` })); + const cache = createLoaderCache({ revalidate: 300 }); + const provider = new ServerLoaderRunner({ page: loader }, cache); + const request = { + loaderId: 'page', + url: '/coalesce', + params: { site: 'demo', locale: 'en' }, + query: {}, + }; + + await provider.resolve(request); + const { key } = buildCacheKey('page', { + url: request.url, + params: request.params, + query: request.query, + }); + await cache.invalidate({ tags: [key] }); + + await Promise.all([provider.resolve(request), provider.resolve(request)]); + + await vi.waitFor(() => expect(loader.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(loader.mock.calls.length).toBe(2); + }); + + it('should warn when background cache write fails but still return stale data', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const loader = vi.fn().mockResolvedValue({ title: 'v2' }); + const cache: LoaderCache = { + get: vi.fn().mockResolvedValue({ kind: 'stale', value: { title: 'v1' }, cacheKey: 'k' }), + set: vi.fn().mockRejectedValue(new Error('write failed')), + invalidate: vi.fn(), + delete: vi.fn(), + flush: vi.fn(), + entries: vi.fn(), + ttl: 300, + enabled: vi.fn().mockReturnValue(true), + config: {}, + }; + + const provider = new ServerLoaderRunner({ page: loader }, cache); + const result = await provider.resolve({ + loaderId: 'page', + url: '/warn', + params: { site: 'demo', locale: 'en' }, + query: {}, + }); + + expect(result).toEqual({ kind: 'data', data: { title: 'v1' } }); + + await vi.waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '[sitecore-loader-cache] background refresh failed to write cache entry:', + 'write failed' + ) + ); + + warnSpy.mockRestore(); + }); +}); diff --git a/packages/angular/src/server/server-loader-runner.ts b/packages/angular/src/server/server-loader-runner.ts new file mode 100644 index 0000000000..317c069f6c --- /dev/null +++ b/packages/angular/src/server/server-loader-runner.ts @@ -0,0 +1,159 @@ +import { + LoaderApiRequest, + LoaderContext, + isLoaderRedirectResult, + LoaderCache, + LoaderDataResult, +} from '../loaders/models'; +import { LoaderRegistry } from '../loaders/loader-registry.token'; +import { buildCacheKey } from './cache/cache-key'; +import { buildLoaderCacheTags } from './cache/cache-tags'; + +/** + * Server-side cache aware loader data resolver. + * {@link LoaderResolver} is exposed to both server and browser. This layer ensures browser safety and acts as connecting layer to cache. + * + * Resolution order when a {@link LoaderCache} is attached: + * 1. **hit** — return cached value immediately. + * 2. **stale** — return cached value immediately and schedule a background refresh + * (coalesced per cache key via `pendingCacheOps`). + * 3. **miss** — run the loader, persist the result with OSR tags, return data. + * + * Redirect responses are never cached. Per-route {@link LoaderCacheConfig} overrides + * from `loaderResolver(id, cacheOptions)` control TTL, tags, and opt-in caching when + * the global cache is disabled. + * @public + */ +export class ServerLoaderRunner { + /** Process-wide coalescing for stale-while-revalidate background refreshes. */ + private static readonly pendingCacheOps = new Set(); + + /** + * @param {LoaderRegistry} registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware. + * @param {LoaderCache | undefined} cache - Optional cache instance from {@link createLoaderCache}. + */ + constructor(private readonly registry: LoaderRegistry, private readonly cache?: LoaderCache) {} + + /** + * Resolve loader data with optional cache read-through and SWR refresh. + * @param {LoaderApiRequest} request - Loader id, URL, params, optional request context and cache overrides. + * @returns {Promise} Data, redirect, or error result for the middleware / SSR resolver. + */ + async resolve(request: LoaderApiRequest): Promise { + const { loaderId, url, params, query, angularRequestContext, cacheOptions } = request; + const loader = this.registry[loaderId]; + if (!loader) { + return { kind: 'error', status: 500, message: `No loader registered for id "${loaderId}"` }; + } + + const ctx: LoaderContext = { url, params, query, requestContext: angularRequestContext }; + + const cacheable = this.cache && (cacheOptions?.enabled ?? this.cache.enabled()); + + if (cacheable) { + const { key } = buildCacheKey(loaderId, ctx); + const read = await this.cache.get(key); + + if (read.kind === 'hit') { + return { kind: 'data', data: read.value }; + } + + if (read.kind === 'stale') { + this.scheduleBackgroundRefresh(request, ctx, key, cacheOptions); + return { kind: 'data', data: read.value }; + } + } + + return this.runLoader({ request, ctx, cacheable: !!cacheable, cacheOptions }); + } + + /** + * Fire-and-forget SWR refresh; skipped when a refresh is already in flight for the key. + * @param {LoaderApiRequest} request - The loader request + * @param {LoaderContext} ctx - The loader context + * @param {string} cacheKey - The cache key + * @param {LoaderApiRequest['cacheOptions']} cacheOptions - The cache options + */ + private scheduleBackgroundRefresh( + request: LoaderApiRequest, + ctx: LoaderContext, + cacheKey: string, + cacheOptions: LoaderApiRequest['cacheOptions'] + ): void { + if (ServerLoaderRunner.pendingCacheOps.has(cacheKey)) { + return; + } + ServerLoaderRunner.pendingCacheOps.add(cacheKey); + void this.runLoader({ + request, + ctx, + cacheable: true, + cacheOptions, + knownCacheKey: cacheKey, + }).then( + () => { + ServerLoaderRunner.pendingCacheOps.delete(cacheKey); + }, + () => { + ServerLoaderRunner.pendingCacheOps.delete(cacheKey); + } + ); + } + + private async runLoader({ + request, + ctx, + cacheable, + cacheOptions, + knownCacheKey, + }: { + request: LoaderApiRequest; + ctx: LoaderContext; + cacheable: boolean; + cacheOptions?: LoaderApiRequest['cacheOptions']; + knownCacheKey?: string; + }): Promise { + const { loaderId } = request; + const loader = this.registry[loaderId]!; + + let value: unknown; + try { + value = await loader(ctx); + } catch (err) { + const message = err instanceof Error ? err.message : 'Loader failed'; + return { + kind: 'error', + status: 500, + message, + ...(err instanceof Error ? { cause: err } : {}), + }; + } + + if (isLoaderRedirectResult(value)) { + return { kind: 'redirect', redirect: value }; + } + + if (cacheable && this.cache) { + const { key, dimensions } = buildCacheKey(loaderId, ctx); + const cacheKey = knownCacheKey ?? key; + const tags = buildLoaderCacheTags( + loaderId, + dimensions, + cacheKey, + value, + cacheOptions?.tags ?? [] + ); + const ttl = cacheOptions?.revalidate ?? this.cache.ttl; + try { + await this.cache.set(cacheKey, value, ttl, tags); + } catch (err) { + console.warn( + '[sitecore-loader-cache] background refresh failed to write cache entry:', + err instanceof Error ? err.message : err + ); + } + } + + return { kind: 'data', data: value }; + } +} diff --git a/packages/angular/src/testing/mock-sitecore-context.ts b/packages/angular/src/testing/mock-sitecore-context.ts index eb47f2af8a..2a9c965dfb 100644 --- a/packages/angular/src/testing/mock-sitecore-context.ts +++ b/packages/angular/src/testing/mock-sitecore-context.ts @@ -1,5 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable */ import { Component, EnvironmentProviders, diff --git a/packages/create-content-sdk-app/src/templates/angular/.env.example b/packages/create-content-sdk-app/src/templates/angular/.env.example index 7cfe651d9c..0012e3f719 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.env.example +++ b/packages/create-content-sdk-app/src/templates/angular/.env.example @@ -14,3 +14,7 @@ # Site / language # CSDK_PUBLIC_SITECORE_DEFAULT_SITE= # CSDK_PUBLIC_SITECORE_DEFAULT_LANGUAGE=en + +# Loader cache (server only; see src/server.ts) +# LOADER_CACHE_DRIVER=unstorage-memory +# LOADER_CACHE_DRIVER=unstorage-fs diff --git a/packages/create-content-sdk-app/src/templates/angular/.gitignore b/packages/create-content-sdk-app/src/templates/angular/.gitignore index 31c0fcbe27..28ea6991ce 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.gitignore +++ b/packages/create-content-sdk-app/src/templates/angular/.gitignore @@ -37,6 +37,9 @@ yarn-error.log .env.prod .env.local +# Loader cache (unstorage fs driver) +.cache/ + # Miscellaneous /.angular/cache .sass-cache/ diff --git a/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs index ff91826627..3b8587411e 100644 --- a/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs +++ b/packages/create-content-sdk-app/src/templates/angular/eslint.config.mjs @@ -55,7 +55,6 @@ export default tseslint.config( 'no-underscore-dangle': 'off', // Sitecore field directives populate host element content at runtime '@angular-eslint/template/elements-content': 'off', - // Navigation matches kit-nextjs-skate-park (click on title row / delegated nav close) '@angular-eslint/template/click-events-have-key-events': 'off', '@angular-eslint/template/interactive-supports-focus': 'off', }, diff --git a/packages/create-content-sdk-app/src/templates/angular/package.json b/packages/create-content-sdk-app/src/templates/angular/package.json index 54e604dcbb..1178b9d1f7 100644 --- a/packages/create-content-sdk-app/src/templates/angular/package.json +++ b/packages/create-content-sdk-app/src/templates/angular/package.json @@ -48,6 +48,8 @@ "dotenv": "^16.5.0", "express": "^5.1.0", "rxjs": "~7.8.0", + "unstorage": "^1.17.5", + "@ngx-translate/core": "^17.0.0", "tailwind-bootstrap-grid": "^6.0.0", "tslib": "^2.3.0" }, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts new file mode 100644 index 0000000000..fd7ccd1e56 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/admin/cache-demo.component.ts @@ -0,0 +1,238 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +interface AdminEntry { + key: string; + tags: string[]; + storedAt: number; + expiresAt: number | null; + stale: boolean; + approxBytes: number; +} + +interface EntriesResponse { + entries: AdminEntry[]; + now: number; +} + +interface ConfigResponse { + namespace: string; + revalidate: number; + enabled: boolean; + loaders: Record; + defaultSiteName: string; + backend: 'memory' | 'unstorage'; +} + +const ADMIN_BASE = '/api/_cache'; + +/** + * Demo page that lists loader-cache entries and supports tag-based invalidation (Phase 3 OSR). + */ +@Component({ + selector: 'app-cache-demo', + standalone: true, + imports: [DatePipe, DecimalPipe, FormsModule], + template: ` +
+
+

Loader cache

+ @if (config(); as c) { +

+ backend: {{ c.backend }} + · revalidate: {{ c.revalidate }}s + · defaultSite: {{ c.defaultSiteName }} + @if (c.namespace) { + · namespace: {{ c.namespace }} + } +

+ } +

+ Entries use sc:loader:… keys and Sitecore OSR tags. Invalidate marks entries + stale (SWR); the next request serves last-known-good while refreshing. +

+
+ +
+ + + @if (lastMessage()) { + {{ lastMessage() }} + } +
+ +
+
+ Invalidate by tag + + +
+
+ + @if (!loading() && entries().length > 0) { +
+ Total entries: {{ entries().length }} + · stale: {{ staleCount() }} +
+ } + + @if (loading()) { +
Loading…
+ } @else if (entries().length === 0) { +
No cache entries yet. Visit a page first.
+ } @else { +
    + @for (entry of entries(); track entry.key) { +
  • +
    {{ entry.key }}
    +
    + @for (t of entry.tags; track t) { + {{ t }} + } +
    +
    + stored: {{ entry.storedAt | date: 'medium' }} + @if (entry.expiresAt) { + expires: {{ entry.expiresAt | date: 'medium' }} + } @else { + expires: never + } + @if (entry.stale) { + stale + } +
    +
    + +
    +
  • + } +
+ } +
+ `, + styles: [ + ` + .cache-demo { padding: 1.5rem; font-family: ui-sans-serif, system-ui, sans-serif; } + .backend { color: #444; font-size: .9rem; margin-top: .25rem; } + .hint { color: #666; max-width: 60ch; } + .toolbar { display: flex; gap: .5rem; align-items: center; margin: 1rem 0; } + .toolbar .status { color: #2a7; font-size: .9rem; } + .invalidate fieldset { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; } + .invalidate label { display: flex; flex-direction: column; font-size: .85rem; color: #444; } + .invalidate input { padding: .25rem .5rem; min-width: 16rem; } + .meta { color: #666; margin: .75rem 0; font-size: .9rem; } + .loading, .empty { color: #888; padding: 1rem 0; } + .entries { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .75rem; } + .entries li { border: 1px solid #ddd; padding: .75rem; border-radius: 6px; background: #fafafa; } + .entries li.stale { border-color: #c90; background: #fffbeb; } + .key { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; word-break: break-all; } + .tags { margin: .35rem 0; display: flex; flex-wrap: wrap; gap: .25rem; } + .tag { font-size: .7rem; background: #eef; padding: .1rem .4rem; border-radius: 3px; font-family: ui-monospace, Consolas, monospace; } + .meta-row { display: flex; gap: 1rem; font-size: .8rem; color: #555; margin-top: .25rem; align-items: center; } + .stale-badge { color: #a60; font-weight: 600; } + .actions { margin-top: .5rem; } + button { padding: .35rem .75rem; cursor: pointer; } + button:disabled { opacity: .5; cursor: not-allowed; } + `, + ], +}) +export class CacheDemoComponent { + private http = inject(HttpClient); + + readonly entries = signal([]); + readonly config = signal(null); + readonly loading = signal(false); + readonly lastMessage = signal(''); + readonly staleCount = computed(() => this.entries().filter((e) => e.stale).length); + + invalidateTags = ''; + + constructor() { + this.refresh(); + } + + async refresh(): Promise { + this.loading.set(true); + try { + const [entries, config] = await Promise.all([ + firstValueFrom(this.http.get(`${ADMIN_BASE}/entries`)), + firstValueFrom(this.http.get(`${ADMIN_BASE}/config`)), + ]); + this.entries.set( + entries.entries.map((e) => ({ + ...e, + approxBytes: e.key.length * 2, + })) + ); + this.config.set(config); + this.lastMessage.set(''); + } catch (err) { + this.lastMessage.set(`Refresh failed: ${(err as Error).message}`); + } finally { + this.loading.set(false); + } + } + + async flush(): Promise { + if (!confirm('Flush every cache entry?')) return; + this.loading.set(true); + try { + await firstValueFrom(this.http.post(`${ADMIN_BASE}/flush`, {})); + this.lastMessage.set('Flushed.'); + await this.refresh(); + } finally { + this.loading.set(false); + } + } + + async invalidate(event: Event): Promise { + event.preventDefault(); + const tags = this.invalidateTags + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + if (tags.length === 0) return; + + this.loading.set(true); + try { + const resp = await firstValueFrom( + this.http.post<{ marked: number }>(`${ADMIN_BASE}/invalidate`, { tags }) + ); + this.lastMessage.set(`Marked ${resp.marked} entr${resp.marked === 1 ? 'y' : 'ies'} stale.`); + await this.refresh(); + } catch (err) { + this.lastMessage.set(`Invalidate failed: ${(err as Error).message}`); + } finally { + this.loading.set(false); + } + } + + async invalidateEntry(entry: AdminEntry): Promise { + this.loading.set(true); + try { + const resp = await firstValueFrom( + this.http.post<{ marked: number }>(`${ADMIN_BASE}/invalidate`, { tags: [entry.key] }) + ); + this.lastMessage.set(`Marked ${resp.marked} entr${resp.marked === 1 ? 'y' : 'ies'} stale.`); + await this.refresh(); + } finally { + this.loading.set(false); + } + } +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts index 41031f1165..67fb56b97b 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.server.ts @@ -2,11 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { provideServerLoaderRunner } from '@sitecore-content-sdk/angular'; const serverConfig: ApplicationConfig = { - providers: [ - provideServerRendering(withRoutes(serverRoutes)) - ] + providers: [provideServerRendering(withRoutes(serverRoutes)), provideServerLoaderRunner()], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts index 871d2505e3..4088eb46a6 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.config.ts @@ -5,7 +5,7 @@ import { provideLoaderRegistry, handleNavigationError, provideSitecoreAngular, - PreLoaderDataService, + ClientPreLoaderDataService, SITECORE_COMPONENT_MAP, SitecoreTranslateLoader, LocaleUrlSerializer, @@ -35,7 +35,7 @@ export const appConfig: ApplicationConfig = { sitecoreClient: getClient(), }), provideLoaderRegistry(LOADERS), - PreLoaderDataService, + ClientPreLoaderDataService, { provide: SITECORE_COMPONENT_MAP, useValue: componentMap }, { provide: TranslateLoader, useClass: SitecoreTranslateLoader }, // provides locale aware serializer for csdk and angular router links diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts index d6f67a31c4..4e2e27e909 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.routes.ts @@ -4,6 +4,7 @@ import scConfig from '../../sitecore.config'; import { PageComponent } from './pages/page.component'; import { NotFoundComponent } from './pages/not-found.component'; import { ErrorComponent } from './pages/error.component'; +import { CacheDemoComponent } from './admin/cache-demo.component'; /** * Error routes (`404` / `500`) live at the top level — both unprefixed and with a @@ -24,6 +25,20 @@ export const routes: Routes = [ { matcher: scLocaleMatcher(scConfig.angular.locales), children: [ + { + path: 'admin/cache', + component: CacheDemoComponent, + }, + { + path: '500', + component: ErrorComponent, + resolve: { page: loaderResolver('500') }, + }, + { + path: '404', + component: NotFoundComponent, + resolve: { page: loaderResolver('404') }, + }, { path: '**', component: PageComponent, diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 45909e85cb..c36879b906 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -7,20 +7,72 @@ import { } from '@angular/ssr/node'; import express from 'express'; import { join } from 'node:path'; -import { createLoaderDataServiceMiddleware } from '@sitecore-content-sdk/angular'; +import fsDriver from 'unstorage/drivers/fs'; +import memoryDriver from 'unstorage/drivers/memory'; +import { + createCacheAdminMiddleware, + createLoaderCache, + createLoaderDataServiceMiddleware, + createSitecoreRevalidateMiddleware, +} from '@sitecore-content-sdk/angular'; import { LOADERS } from './content-sdk/loaders'; +import config from '../sitecore.config'; const browserDistFolder = join(import.meta.dirname, '../browser'); const app = express(); const angularApp = new AngularNodeAppEngine(); +/** + * Loader cache driver selection (server only). + * + * LOADER_CACHE_DRIVER unset → in-memory Map (default) + * LOADER_CACHE_DRIVER=unstorage-memory → unstorage with memory driver + * LOADER_CACHE_DRIVER=unstorage-fs → unstorage with fs driver (persists) + * + * The fs driver writes to `./.cache/loaders/.json`, surviving process restarts. + */ +const driverChoice = process.env.LOADER_CACHE_DRIVER; +const driver = + driverChoice === 'unstorage-fs' + ? fsDriver({ base: './.cache/loaders' }) + : driverChoice === 'unstorage-memory' + ? memoryDriver() + : undefined; + +const loaderCache = createLoaderCache({ + revalidate: config.angular.loadersCache.revalidate, + enabled: config.angular.loadersCache.enabled, + defaultSiteName: config.defaultSite, + ...(driver ? { driver } : {}), +}); + +app.use(express.json()); + +/** Production webhook: POST /api/revalidate (Sitecore Edge OSR). */ +app.use( + createSitecoreRevalidateMiddleware({ + cache: loaderCache, + defaultLocale: config.defaultLanguage, + sites: [ + { + name: config.defaultSite, + hostName: '*', + language: config.defaultLanguage, + }, + ], + }) +); + +/** Admin endpoints for cache inspection and invalidation (see `/api/_cache`). */ +app.use(createCacheAdminMiddleware({ cache: loaderCache, endpoint: '/api/_cache' })); + /** * Loader data endpoint (/_data). Must use the same loaders as the client registry * so client-side navigation can fetch route data via POST /_data. */ -app.use(express.json()); -app.use(createLoaderDataServiceMiddleware({ loaders: LOADERS })); +app.use(createLoaderDataServiceMiddleware({ loaders: LOADERS, cache: loaderCache })); + /** * Serve static files from /browser */ @@ -34,11 +86,12 @@ app.use( /** * Handle all other requests by rendering the Angular application. - * Catches ExternalRedirectError from loaders so server-side external redirects send HTTP 302. + * The cache reference rides on REQUEST_CONTEXT so the SSR loader resolver + * picks it up via inject(REQUEST_CONTEXT). */ app.use((req, res, next) => { angularApp - .handle(req) + .handle(req, { cache: loaderCache }) .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) .catch((err) => { next(err); diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts index a887bed80d..9692e953e9 100644 --- a/packages/nextjs/src/cache/sitecore-cache-tags.test.ts +++ b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts @@ -38,7 +38,11 @@ describe('sitecore-cache-tags', () => { it('joins path segments', () => { expect( - buildSitecoreRouteCacheTag({ site: 'Website', locale: 'en-US', pathSegments: ['About', 'Team'] }) + buildSitecoreRouteCacheTag({ + site: 'Website', + locale: 'en-US', + pathSegments: ['About', 'Team'], + }) ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:website:en-us:about/team`); }); }); @@ -121,7 +125,9 @@ describe('sitecore-cache-tags', () => { } as RouteData, 'en-US' ) - ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111-1111-1111-1111-111111111111:fr-fr:v2`); + ).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111-1111-1111-1111-111111111111:fr-fr:v2` + ); }); it('falls back to fallbackLocale', () => { diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.ts b/packages/nextjs/src/cache/sitecore-cache-tags.ts index 681619882b..95f65e9079 100644 --- a/packages/nextjs/src/cache/sitecore-cache-tags.ts +++ b/packages/nextjs/src/cache/sitecore-cache-tags.ts @@ -14,7 +14,10 @@ export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; * @internal */ export function sanitizeSitecoreCacheTagSegment(value: string): string { - return value.trim().toLowerCase().replace(/[/:\s]+/g, '_'); + return value + .trim() + .toLowerCase() + .replace(/[/:\s]+/g, '_'); } /** @@ -95,7 +98,9 @@ export type BuildSitecoreDictionaryCacheTagParams = { * @param {BuildSitecoreDictionaryCacheTagParams} params - Site and locale for the dictionary fetch. * @public */ -export function buildSitecoreDictionaryCacheTag(params: BuildSitecoreDictionaryCacheTagParams): string { +export function buildSitecoreDictionaryCacheTag( + params: BuildSitecoreDictionaryCacheTagParams +): string { const site = sanitizeSitecoreCacheTagSegment(params.site); const locale = sanitizeSitecoreCacheTagSegment(params.locale); return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; diff --git a/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts index 41e9e6d17b..a6c5c8f012 100644 --- a/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts +++ b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts @@ -47,9 +47,15 @@ describe('collectSitecorePageCacheTags', () => { ...base, personalizedPathname: '/about', }); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`))).to.equal(true); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:`))).to.equal(true); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal(false); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`))).to.equal( + true + ); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:`))).to.equal( + true + ); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal( + false + ); }); it('does not add a personalization variant tag even when pathname carries variant markers', () => { @@ -57,6 +63,8 @@ describe('collectSitecorePageCacheTags', () => { ...base, personalizedPathname: '/about/_variantId_hero-a', }); - expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal(false); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal( + false + ); }); }); diff --git a/yarn.lock b/yarn.lock index cbd4a701cf..e56a6d4818 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4911,6 +4911,7 @@ __metadata: rxjs: "npm:~7.8.0" tslib: "npm:^2.3.0" typescript: "npm:~5.9.2" + unstorage: "npm:^1.17.5" vitest: "npm:^4.0.8" zone.js: "npm:^0.15.0" peerDependencies: @@ -8228,6 +8229,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^1.2.3": + version: 1.2.3 + resolution: "cookie-es@npm:1.2.3" + checksum: 10/899f72d6354de72522ccf01c990c4f6caf8dd3180bd3cb426ea4be495af5acab6e74631e319b969285001ddecf9ea8a0657f71bf4dcd433238f2acc638f36d6f + languageName: node + linkType: hard + "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -8391,6 +8399,15 @@ __metadata: languageName: node linkType: hard +"crossws@npm:^0.3.5": + version: 0.3.5 + resolution: "crossws@npm:0.3.5" + dependencies: + uncrypto: "npm:^0.1.3" + checksum: 10/70a38525543293f88381b64650324c9de4a7e8a4dd86edf29e702b317d0d9fed2fb128a176242c90aa58d83acc64e62d35c919029f698a9868766b465430cd99 + languageName: node + linkType: hard + "css-select@npm:^6.0.0": version: 6.0.0 resolution: "css-select@npm:6.0.0" @@ -8720,6 +8737,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.6": + version: 6.1.7 + resolution: "defu@npm:6.1.7" + checksum: 10/09480a5fbe6318f622f30017f9386df6ae92ed895fb1ccc61e1ff0d5016b28a321c751749fdd52c996ddd4eafc2c95b77dc0c8cc109881a231c23c7fd630deb9 + languageName: node + linkType: hard + "del-cli@npm:^6.0.0": version: 6.0.0 resolution: "del-cli@npm:6.0.0" @@ -8797,6 +8821,13 @@ __metadata: languageName: node linkType: hard +"destr@npm:^2.0.5": + version: 2.0.5 + resolution: "destr@npm:2.0.5" + checksum: 10/0e4fba62a55a4188c7ab13eed5ebeeda037ead1ab21cf6be40ca39828b258475ad9eb1e7de50a5ea8041705d454a4d090caf9f92b89f03b04d2e229716f7da0a + languageName: node + linkType: hard + "detect-indent@npm:^6.0.0": version: 6.1.0 resolution: "detect-indent@npm:6.1.0" @@ -10936,6 +10967,23 @@ __metadata: languageName: node linkType: hard +"h3@npm:^1.15.10": + version: 1.15.11 + resolution: "h3@npm:1.15.11" + dependencies: + cookie-es: "npm:^1.2.3" + crossws: "npm:^0.3.5" + defu: "npm:^6.1.6" + destr: "npm:^2.0.5" + iron-webcrypto: "npm:^1.2.1" + node-mock-http: "npm:^1.0.4" + radix3: "npm:^1.1.2" + ufo: "npm:^1.6.3" + uncrypto: "npm:^0.1.3" + checksum: 10/8a13eef49f076eedf1aa6b32ab9190c647cbae517ed2945c951905ef018e2949dd0baa73d0954dc17eaf432d1657e3a09a10ebe5ac5532365083d3560d17d8b5 + languageName: node + linkType: hard + "handlebars@npm:^4.7.7, handlebars@npm:^4.7.9": version: 4.7.9 resolution: "handlebars@npm:4.7.9" @@ -11509,6 +11557,13 @@ __metadata: languageName: node linkType: hard +"iron-webcrypto@npm:^1.2.1": + version: 1.2.1 + resolution: "iron-webcrypto@npm:1.2.1" + checksum: 10/c1f52ccfe2780efa5438c134538ee4b26c96a87d22f351d896781219efbce25b4fe716d1cb7f248e02da96881760541135acbcc7c0622ffedf71cb0e227bebf9 + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -13683,6 +13738,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.2.7": + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10/02c4f73967d91fb101f4accf8ebac9e0541e08e16d987bdb9e9737f13e5f2c4bc33c593b98ec30e4486bf899bc97edb36fbd133684b36087336559e41edafdea + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -14539,6 +14601,13 @@ __metadata: languageName: node linkType: hard +"node-fetch-native@npm:^1.6.7": + version: 1.6.7 + resolution: "node-fetch-native@npm:1.6.7" + checksum: 10/b8a99e6adafbdbdd9373a6784c467ca5c7b95eeed4896ee2d604f0729962fda8d07cf7a85edd1e8bb3ee51e791dc55c30cbebeb46cbd1f086d74141b3769a680 + languageName: node + linkType: hard + "node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -14593,6 +14662,13 @@ __metadata: languageName: node linkType: hard +"node-mock-http@npm:^1.0.4": + version: 1.0.4 + resolution: "node-mock-http@npm:1.0.4" + checksum: 10/865bcc502a0b59f5504d014561ab0e3f4d8217c6b4022b621a1515503beaf1f526bb44cab43adb172e453992f75148ed13cc371bc6a3df1ad853430bf4bf8c62 + languageName: node + linkType: hard + "node-preload@npm:^0.2.1": version: 0.2.1 resolution: "node-preload@npm:0.2.1" @@ -15142,6 +15218,17 @@ __metadata: languageName: node linkType: hard +"ofetch@npm:^1.5.1": + version: 1.5.1 + resolution: "ofetch@npm:1.5.1" + dependencies: + destr: "npm:^2.0.5" + node-fetch-native: "npm:^1.6.7" + ufo: "npm:^1.6.1" + checksum: 10/2a1a9bf4f97eb5fe5ef52e87dc3f1fe01a335e6d57c8020a5eb557b4691f4d35b045a4c2d9c22d925945c5263de9fd5084efd5670bde7ade5f0fe6dfd797a346 + languageName: node + linkType: hard + "on-finished@npm:^2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -16233,6 +16320,13 @@ __metadata: languageName: node linkType: hard +"radix3@npm:^1.1.2": + version: 1.1.2 + resolution: "radix3@npm:1.1.2" + checksum: 10/5ed01a8e4b753e325c6ecb01d993de77f690e548ef9e149e7dc403ee7b109c2cb41e3d09bc3ce004d872c67c8dca1d556dbf7808b1ac7df9f86994e57d757557 + languageName: node + linkType: hard + "randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -18751,6 +18845,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.6.1, ufo@npm:^1.6.3": + version: 1.6.4 + resolution: "ufo@npm:1.6.4" + checksum: 10/dbf85425e00dd106abb852c0ea4cef6e58b395b9a43858049a8be0b0825e5cc4b53cf58a41da695c3c2a9ab4f8605923b64812be1358c39a56b3920504759d3a + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -18772,6 +18873,13 @@ __metadata: languageName: node linkType: hard +"uncrypto@npm:^0.1.3": + version: 0.1.3 + resolution: "uncrypto@npm:0.1.3" + checksum: 10/0020f74b0ce34723196d8982a73bb7f40cff455a41b8f88ae146b86885f4e66e41a1241fe80a887505c3bd2c7f07ed362b6ed041968370073c40a98496e6a737 + languageName: node + linkType: hard + "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0" @@ -18909,6 +19017,81 @@ __metadata: languageName: node linkType: hard +"unstorage@npm:^1.17.5": + version: 1.17.5 + resolution: "unstorage@npm:1.17.5" + dependencies: + anymatch: "npm:^3.1.3" + chokidar: "npm:^5.0.0" + destr: "npm:^2.0.5" + h3: "npm:^1.15.10" + lru-cache: "npm:^11.2.7" + node-fetch-native: "npm:^1.6.7" + ofetch: "npm:^1.5.1" + ufo: "npm:^1.6.3" + peerDependencies: + "@azure/app-configuration": ^1.8.0 + "@azure/cosmos": ^4.2.0 + "@azure/data-tables": ^13.3.0 + "@azure/identity": ^4.6.0 + "@azure/keyvault-secrets": ^4.9.0 + "@azure/storage-blob": ^12.26.0 + "@capacitor/preferences": ^6 || ^7 || ^8 + "@deno/kv": ">=0.9.0" + "@netlify/blobs": ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + "@planetscale/database": ^1.19.0 + "@upstash/redis": ^1.34.3 + "@vercel/blob": ">=0.27.1" + "@vercel/functions": ^2.2.12 || ^3.0.0 + "@vercel/kv": ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: ">=0.2.1" + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + "@azure/app-configuration": + optional: true + "@azure/cosmos": + optional: true + "@azure/data-tables": + optional: true + "@azure/identity": + optional: true + "@azure/keyvault-secrets": + optional: true + "@azure/storage-blob": + optional: true + "@capacitor/preferences": + optional: true + "@deno/kv": + optional: true + "@netlify/blobs": + optional: true + "@planetscale/database": + optional: true + "@upstash/redis": + optional: true + "@vercel/blob": + optional: true + "@vercel/functions": + optional: true + "@vercel/kv": + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + checksum: 10/e0059c08e87a86d2a43dc2a49853fd6bc324f655f3dba59fec2b1eb59bb784495772013a5925017a2970d63b9921d31d9211d42a361f810d3c20bded57c13d46 + languageName: node + linkType: hard + "upath@npm:2.0.1": version: 2.0.1 resolution: "upath@npm:2.0.1"