diff --git a/.changeset/hip-buttons-boil.md b/.changeset/hip-buttons-boil.md new file mode 100644 index 0000000000..bef636cab0 --- /dev/null +++ b/.changeset/hip-buttons-boil.md @@ -0,0 +1,5 @@ +--- +'@sitecore-content-sdk/angular': minor +--- + +Personalize, multisite and analytics support diff --git a/packages/angular/config/ng-package.json b/packages/angular/config/ng-package.json new file mode 100644 index 0000000000..906f9e939a --- /dev/null +++ b/packages/angular/config/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "../src/config/index.ts" + } +} diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index a840ee32a6..defb5ae22e 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -7,6 +7,9 @@ "allowedNonPeerDependencies": [ "@sitecore-content-sdk/core", "@sitecore-content-sdk/content", + "@sitecore-content-sdk/analytics-core", + "@sitecore-content-sdk/events", + "@sitecore-content-sdk/personalize", "@angular/common", "@angular/core", "@angular/router", diff --git a/packages/angular/package.json b/packages/angular/package.json index 27e9054fa9..64887a7985 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -12,16 +12,16 @@ }, "files": [ "dist", - "cli-node" + "dist-node" ], "scripts": { "ng": "ng", - "build": "del-cli dist cli-node && ng build && tsc -p tsconfig.server.json", + "build": "del-cli dist dist-node && ng build && tsc -p tsconfig.server.json", "watch": "ng build --watch --configuration development", "test": "ng test --watch=false", "coverage": "ng test --watch=false --coverage", "lint": "eslint \"./src/**/*.ts\"", - "generate-docs": "npx typedoc --options typedoc.json --plugin typedoc-plugin-markdown --outputFileStrategy Members --parametersFormat table --readme none --tsconfig tsconfig.typedoc.json --out ../../ref-docs/angular --entryPoints src/config/index.ts --entryPoints src/config-cli/index.ts --entryPoints src/lib/index.ts --entryPoints src/loaders/index.ts --entryPoints src/i18n/index.ts --entryPoints src/components/index.ts --entryPoints src/components/field-directives/index.ts --entryPoints src/components/placeholder/index.ts --entryPoints src/server/express/index.ts --entryPoints src/server/cache/index.ts --entryPoints src/server/middleware/index.ts --githubPages false" + "generate-docs": "npx typedoc --options typedoc.json --plugin typedoc-plugin-markdown --outputFileStrategy Members --parametersFormat table --readme none --tsconfig tsconfig.typedoc.json --out ../../ref-docs/angular --entryPoints src/config/index.ts --entryPoints src/config-cli/index.ts --entryPoints src/lib/index.ts --entryPoints src/loaders/index.ts --entryPoints src/i18n/index.ts --entryPoints src/components/index.ts --entryPoints src/components/field-directives/index.ts --entryPoints src/components/placeholder/index.ts --entryPoints src/server/cache/index.ts --entryPoints src/server/middleware/index.ts --githubPages false" }, "prettier": { "printWidth": 100, @@ -51,6 +51,9 @@ "@angular/platform-server": "^21.0.0", "@angular/ssr": "^21.0.0", "@ngx-translate/core": "^17.0.0", + "@sitecore-content-sdk/analytics-core": "^2.1.0", + "@sitecore-content-sdk/events": "^2.1.0", + "@sitecore-content-sdk/personalize": "^2.1.0", "zone.js": "^0.15.0" }, "peerDependenciesMeta": { @@ -63,8 +66,11 @@ }, "dependencies": { "@ngx-translate/core": "^17.0.0", + "@sitecore-content-sdk/analytics-core": "^2.1.0", "@sitecore-content-sdk/content": "^2.1.0", "@sitecore-content-sdk/core": "^2.1.0", + "@sitecore-content-sdk/events": "^2.1.0", + "@sitecore-content-sdk/personalize": "^2.1.0", "tslib": "^2.3.0", "unstorage": "^1.17.5" }, @@ -101,6 +107,10 @@ "types": "./dist/types/sitecore-content-sdk-angular.d.ts", "default": "./dist/fesm2022/sitecore-content-sdk-angular.mjs" }, + "./config": { + "types": "./dist/types/sitecore-content-sdk-angular-config.d.ts", + "default": "./dist/fesm2022/sitecore-content-sdk-angular-config.mjs" + }, "./config-cli": { "types": "./dist-node/server/config-cli/index.d.ts", "default": "./dist-node/server/config-cli/index.js" diff --git a/packages/angular/src/components/field-directives/sc-router-link.directive.ts b/packages/angular/src/components/field-directives/sc-router-link.directive.ts index b93cdd917e..303628a549 100644 --- a/packages/angular/src/components/field-directives/sc-router-link.directive.ts +++ b/packages/angular/src/components/field-directives/sc-router-link.directive.ts @@ -62,7 +62,7 @@ export class ScRouterLinkDirective extends ScLinkDirective { return; } - if (this.sitecoreContext.isEditing()) { + if (this.sitecoreContext.isEditing() || this.sitecoreContext.isPreview()) { return; } diff --git a/packages/angular/src/components/sc-form.component.spec.ts b/packages/angular/src/components/sc-form.component.spec.ts index 6ba2862254..220bed2fc7 100644 --- a/packages/angular/src/components/sc-form.component.spec.ts +++ b/packages/angular/src/components/sc-form.component.spec.ts @@ -260,7 +260,7 @@ describe('ScFormComponent', () => { expect(elArg.tagName).toBe('DIV'); }); - it('should call subscribeToFormSubmitEvent when not in editing mode', async () => { + it('should subscribe to form submit events when not in editing mode', async () => { const fixture = createFixture(); setMockContextPage(makePage(false)); @@ -271,13 +271,13 @@ describe('ScFormComponent', () => { await flushFormLoadPipeline(fixture); expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledTimes(1); - expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledWith( - expect.any(HTMLElement), - 'comp-uid-1' - ); + const [elArg, componentId, signalArg] = mocks.subscribeToFormSubmitEvent.mock.calls[0]; + expect((elArg as HTMLDivElement).tagName).toBe('DIV'); + expect(componentId).toBe('comp-uid-1'); + expect(signalArg).toBeInstanceOf(AbortSignal); }); - it('should not call subscribeToFormSubmitEvent in editing mode', async () => { + it('should not subscribe to form submit events in editing mode', async () => { const fixture = createFixture(); setMockContextPage(makePage(true)); diff --git a/packages/angular/src/components/sc-form.component.ts b/packages/angular/src/components/sc-form.component.ts index ec33dd22b2..9c5af67a8f 100644 --- a/packages/angular/src/components/sc-form.component.ts +++ b/packages/angular/src/components/sc-form.component.ts @@ -67,8 +67,10 @@ export class ScFormComponent { } let cancelled = false; + const abort = new AbortController(); this.destroyRef.onDestroy(() => { cancelled = true; + abort.abort(); }); loadForm(edgeId, formId, edgeUrl) @@ -81,7 +83,7 @@ export class ScFormComponent { const isEditing = this.context.isEditing(); if (!isEditing) { - subscribeToFormSubmitEvent(el, this.rendering()?.uid); + subscribeToFormSubmitEvent(el, this.rendering()?.uid, abort.signal); } executeScriptElements(el); diff --git a/packages/angular/src/config/define-config.ts b/packages/angular/src/config/define-config.ts index a8c8451086..a01a19cf36 100644 --- a/packages/angular/src/config/define-config.ts +++ b/packages/angular/src/config/define-config.ts @@ -1,6 +1,6 @@ -import type { SitecoreConfig, SitecoreConfigInput } from '@sitecore-content-sdk/content/config'; +import type { DeepRequired, SitecoreConfigInput } from '@sitecore-content-sdk/content/config'; import { defineConfig as baseDefineConfig } from '@sitecore-content-sdk/content/config'; - +import type { ExpressRequest, ExpressResponse } from './http-types'; /** * Reads `process.env` when running under Node; otherwise returns an empty object. * @returns {Record} Environment map for merging into config. @@ -19,12 +19,7 @@ function getProcessEnv(): Record { * source of truth for the locale list. * @public */ -export interface AngularSitecoreConfigInput extends Omit { - /** - * Settings for redirects functionality. `locales` is derived automatically from - * `angular.locales`; only `enabled` is configurable at this layer. - */ - redirects?: Omit, 'locales'>; +export interface AngularSitecoreConfigInput extends Omit { /** Angular-specific configuration. */ angular?: { /** @@ -43,6 +38,13 @@ export interface AngularSitecoreConfigInput extends Omit boolean; + }; } /** @@ -52,21 +54,7 @@ export interface AngularSitecoreConfigInput extends Omit { - redirects: 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; - }; - }; -} +export type AngularSitecoreConfig = DeepRequired; /** Defaults applied to `angular.loadersCache` when input omits fields. */ const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 } as const; diff --git a/packages/angular/src/config/http-types.ts b/packages/angular/src/config/http-types.ts new file mode 100644 index 0000000000..2f6379d05f --- /dev/null +++ b/packages/angular/src/config/http-types.ts @@ -0,0 +1,60 @@ +/** + * Minimal Express Request interface for type safety without requiring Express as a dependency + * @public + */ +export interface ExpressRequest { + method: string; + path: string; + url: string; + body: unknown; + referrer?: string; + query: Record; + /** + * Cookies from the request (requires cookie-parser middleware) + */ + cookies?: Record; + /** + * Headers from the request + */ + headers?: Record; + setHeader?: (name: string, value: string | string[] | undefined) => void; +} + +/** + * Minimal Express Response interface for type safety without requiring Express as a dependency + * @public + */ +export interface ExpressResponse { + status(code: number): ExpressResponse; + json(data: unknown): void; + /** + * Send a raw response body (string, Buffer, null, etc.). Used for HTML + * responses (editing render endpoint) and 204 no-content replies. + */ + send?(body: unknown): void; + /** + * Set a response header. Used by editing middleware to apply CORS / CSP + * headers without depending on Express types directly. + */ + setHeader?(name: string, value: string | string[]): void; + /** + * Set a response cookie. Used by multisite middleware to set the site cookie. + */ + cookie?(name: string, value: string, options?: CookieOptions): void; +} +export interface CookieOptions { + /** Expiry relative to now, in milliseconds */ + maxAge?: number; + /** Sign the cookie (needs cookie-parser with a secret) */ + signed?: boolean; + /** GMT expiry date; omit for session cookie */ + expires?: Date; + httpOnly?: boolean; + path?: string; // default "/" + domain?: string; + secure?: boolean; + encode?: (val: string) => string; + sameSite?: boolean | 'lax' | 'strict' | 'none'; + priority?: 'low' | 'medium' | 'high'; + partitioned?: boolean; +} diff --git a/packages/angular/src/lib/analytics/sitecore-analytics.browser.ts b/packages/angular/src/lib/analytics/sitecore-analytics.browser.ts new file mode 100644 index 0000000000..7f94ae8966 --- /dev/null +++ b/packages/angular/src/lib/analytics/sitecore-analytics.browser.ts @@ -0,0 +1,108 @@ +import { Injectable, inject, isDevMode } from '@angular/core'; +import { initContentSdk } from '@sitecore-content-sdk/core'; +import { analyticsBrowserAdapter, analyticsPlugin } from '@sitecore-content-sdk/analytics-core'; +import { event, eventsPlugin, form, identity, pageView } from '@sitecore-content-sdk/events'; +import type { EventData, IdentityData, PageViewData } from '@sitecore-content-sdk/events'; +import { SITECORE_CONFIG_TOKEN } from '../tokens'; +import { normalizeCookieDomain, type SitecoreAnalyticsWrapper } from './sitecore-analytics'; +import debug from '../../debug'; + +/** Dedup window for identical page-view fingerprints (hydration replay, transfer-state restore). */ +const DEDUP_WINDOW_MS = 1000; + +/** + * Browser {@link SitecoreAnalyticsWrapper} implementation. Lazily initializes the Content SDK + * events runtime on first dispatch — memoized so concurrent first callers await the same promise + * — then forwards to the `@sitecore-content-sdk/events` functions. Failures are swallowed at debug + * level: analytics must never break the app. + * @public + */ +@Injectable() +export class SitecoreAnalyticsBrowser implements SitecoreAnalyticsWrapper { + private readonly config = inject(SITECORE_CONFIG_TOKEN, { optional: true }); + /** Memoized one-time init; resolves `false` when analytics is disabled (dev / missing Edge). */ + private initPromise?: Promise; + /** Last page-view fingerprint + timestamp, for same-payload dedup. */ + private lastPageView?: { fingerprint: string; at: number }; + + pageView(data: PageViewData): Promise { + const fingerprint = `${data.page ?? ''}|${data.pageVariantId ?? ''}|${data.language ?? ''}`; + const now = Date.now(); + if ( + this.lastPageView?.fingerprint === fingerprint && + now - this.lastPageView.at < DEDUP_WINDOW_MS + ) { + debug.common('analytics pageView deduped: %s', fingerprint); + return Promise.resolve(); + } + this.lastPageView = { fingerprint, at: now }; + return this.dispatch(() => pageView(data)); + } + + event(data: EventData): Promise { + return this.dispatch(() => event(data)); + } + + identity(data: IdentityData): Promise { + return this.dispatch(() => identity(data)); + } + + form( + formId: string, + interactionType: 'VIEWED' | 'SUBMITTED', + componentInstanceId: string + ): Promise { + return this.dispatch(() => form(formId, interactionType, componentInstanceId)); + } + + /** + * Ensure the SDK is initialized, then run `send`; swallow any failure at debug level. + * @param {() => Promise} send - Events dispatch call to run once initialized. + */ + private async dispatch(send: () => Promise): Promise { + try { + const initialized = await this.ensureInit(); + if (!initialized) return; + await send(); + } catch (e) { + debug.common('analytics dispatch failed: %o', e); + } + } + + private ensureInit(): Promise { + if (!this.initPromise) { + this.initPromise = this.doInit(); + } + return this.initPromise; + } + + private async doInit(): Promise { + if (isDevMode()) { + debug.common('Browser Events SDK is not initialized in development environment'); + return false; + } + const edge = this.config?.api?.edge; + if (!edge?.clientContextId) { + debug.common('Client Edge API settings missing from configuration; analytics disabled'); + return false; + } + await initContentSdk({ + config: { + contextId: edge.clientContextId, + edgeUrl: edge.edgeUrl, + siteName: this.config?.defaultSite ?? '', + }, + plugins: [ + analyticsPlugin({ + options: { + enableCookie: true, + cookieDomain: normalizeCookieDomain(window.location.hostname), + }, + adapter: analyticsBrowserAdapter(), + }), + eventsPlugin(), + ], + }); + return true; + } +} diff --git a/packages/angular/src/lib/analytics/sitecore-analytics.server.ts b/packages/angular/src/lib/analytics/sitecore-analytics.server.ts new file mode 100644 index 0000000000..e03ac60682 --- /dev/null +++ b/packages/angular/src/lib/analytics/sitecore-analytics.server.ts @@ -0,0 +1,134 @@ +import { Injectable, REQUEST_CONTEXT, inject } from '@angular/core'; +import { initContentSdk } from '@sitecore-content-sdk/core'; +import { analyticsPlugin, analyticsServerAdapter } from '@sitecore-content-sdk/analytics-core'; +import { event, eventsPlugin, form, identity, pageView } from '@sitecore-content-sdk/events'; +import type { EventData, IdentityData, PageViewData } from '@sitecore-content-sdk/events'; +import { SITECORE_CONFIG_TOKEN } from '../tokens'; +import { normalizeCookieDomain, type SitecoreAnalyticsWrapper } from './sitecore-analytics'; +import debug from '../../debug'; + +/** + * Minimal Node request shape the server analytics adapter reads from `REQUEST_CONTEXT`. Declared + * locally (only the `headers` field we touch) to keep `@types/node` out of the browser-bundled lib + * build; the real Express request/response objects flow through at runtime. + */ +interface NodeLikeRequest { + headers: Record; +} + +/** Shape of the SSR request context passed to `AngularNodeAppEngine.handle(req, ctx)` in server.ts. */ +interface SsrAnalyticsContext { + req?: NodeLikeRequest; + res?: object; +} + +/** Server analytics adapter param types (node req/res), derived without importing node types. */ +type AdapterRequest = Parameters[0]; +type AdapterResponse = Parameters[1]; + +/** + * Server-side {@link SitecoreAnalyticsWrapper} implementation. Unlike a no-op, it dispatches CDP + * events from SSR using the **server** analytics provider: it lazily initializes the Content SDK + * with `analyticsServerAdapter` (cookie-based visitor identity from the Node request/response) and + * the events plugin, then forwards to the `@sitecore-content-sdk/events` functions. Failures are + * swallowed at debug level so analytics never breaks rendering. + * + * The Node `req`/`res` are taken from `REQUEST_CONTEXT` — `server.ts` passes them via + * `angularApp.handle(req, { cache, req, res })`. When they are absent (or Edge config is missing), + * the wrapper degrades to a no-op. + * + * NOTE: `initContentSdk` writes the module-global core context. Per-request init in a long-lived + * Node process can race under concurrency (same caveat as the personalize middleware); the + * explicit-context engine API is the planned fix. + * @public + */ +@Injectable() +export class SitecoreAnalyticsServer implements SitecoreAnalyticsWrapper { + private readonly config = inject(SITECORE_CONFIG_TOKEN, { optional: true }); + private readonly ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as + | SsrAnalyticsContext + | undefined; + /** Memoized one-time init for this request; resolves `false` when analytics is disabled. */ + private initPromise?: Promise; + + pageView(data: PageViewData): Promise { + return this.dispatch(() => pageView(data)); + } + + event(data: EventData): Promise { + return this.dispatch(() => event(data)); + } + + identity(data: IdentityData): Promise { + return this.dispatch(() => identity(data)); + } + + form( + formId: string, + interactionType: 'VIEWED' | 'SUBMITTED', + componentInstanceId: string + ): Promise { + return this.dispatch(() => form(formId, interactionType, componentInstanceId)); + } + + /** + * Ensure the SDK is initialized with server adapters, then run `send`; swallow failures. + * @param {() => Promise} send - Events dispatch call to run once initialized. + */ + private async dispatch(send: () => Promise): Promise { + try { + const initialized = await this.ensureInit(); + if (!initialized) return; + await send(); + } catch (e) { + debug.common('server analytics dispatch failed: %o', e); + } + } + + private ensureInit(): Promise { + if (!this.initPromise) { + this.initPromise = this.doInit(); + } + return this.initPromise; + } + + private async doInit(): Promise { + const edge = this.config?.api?.edge; + if (!edge?.contextId) { + debug.common('Edge contextId missing from configuration; server analytics disabled'); + return false; + } + const req = this.ssrContext?.req; + const res = this.ssrContext?.res; + if (!req || !res) { + debug.common('Node req/res not available in REQUEST_CONTEXT; server analytics disabled'); + return false; + } + + const hostHeader = req.headers['x-forwarded-host'] ?? req.headers.host; + const hostname = + (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader)?.split(':')[0] || 'localhost'; + + await initContentSdk({ + config: { + contextId: edge.contextId, + edgeUrl: edge.edgeUrl, + siteName: this.config?.defaultSite ?? '', + }, + plugins: [ + analyticsPlugin({ + options: { + enableCookie: true, + cookieDomain: normalizeCookieDomain(hostname), + }, + adapter: analyticsServerAdapter( + req as unknown as AdapterRequest, + res as unknown as AdapterResponse + ), + }), + eventsPlugin(), + ], + }); + return true; + } +} diff --git a/packages/angular/src/lib/analytics/sitecore-analytics.spec.ts b/packages/angular/src/lib/analytics/sitecore-analytics.spec.ts new file mode 100644 index 0000000000..fecb0bbba8 --- /dev/null +++ b/packages/angular/src/lib/analytics/sitecore-analytics.spec.ts @@ -0,0 +1,371 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; +import { enableProdMode, PLATFORM_ID, REQUEST_CONTEXT } from '@angular/core'; +import type { EventData, IdentityData, PageViewData } from '@sitecore-content-sdk/events'; +import type { AngularSitecoreConfig } from '../../config/define-config'; +import { SITECORE_CONFIG_TOKEN } from '../tokens'; +import { + SITECORE_ANALYTICS, + normalizeCookieDomain, + type SitecoreAnalyticsWrapper, +} from './sitecore-analytics'; + +const mocks = vi.hoisted(() => ({ + initContentSdk: vi.fn(), + pageView: vi.fn(), + event: vi.fn(), + identity: vi.fn(), + form: vi.fn(), + eventsPlugin: vi.fn(() => ({ name: 'events' })), + analyticsPlugin: vi.fn(() => ({ name: 'analytics' })), + analyticsBrowserAdapter: vi.fn(() => ({ name: 'browser-adapter' })), + analyticsServerAdapter: vi.fn(() => ({ name: 'server-adapter' })), +})); + +vi.mock('@sitecore-content-sdk/core', async (importOriginal) => ({ + ...(await importOriginal>()), + initContentSdk: mocks.initContentSdk, +})); +vi.mock('@sitecore-content-sdk/events', () => ({ + pageView: mocks.pageView, + event: mocks.event, + identity: mocks.identity, + form: mocks.form, + eventsPlugin: mocks.eventsPlugin, +})); +vi.mock('@sitecore-content-sdk/analytics-core', () => ({ + analyticsPlugin: mocks.analyticsPlugin, + analyticsBrowserAdapter: mocks.analyticsBrowserAdapter, + analyticsServerAdapter: mocks.analyticsServerAdapter, +})); + +type ProvideSitecoreAngular = typeof import('../providers').provideSitecoreAngular; +type SitecoreAnalyticsBrowserCtor = typeof import('./sitecore-analytics.browser').SitecoreAnalyticsBrowser; +type SitecoreAnalyticsServerCtor = typeof import('./sitecore-analytics.server').SitecoreAnalyticsServer; + +let provideSitecoreAngular: ProvideSitecoreAngular; +let SitecoreAnalyticsBrowser: SitecoreAnalyticsBrowserCtor; +let SitecoreAnalyticsServer: SitecoreAnalyticsServerCtor; + +beforeAll(async () => { + enableProdMode(); + ({ provideSitecoreAngular } = await import('../providers')); + ({ SitecoreAnalyticsBrowser } = await import('./sitecore-analytics.browser')); + ({ SitecoreAnalyticsServer } = await import('./sitecore-analytics.server')); +}); + +const browserConfig = { + defaultSite: 'site-a', + api: { edge: { clientContextId: 'client-ctx', edgeUrl: 'https://edge.example.com' } }, +} as unknown as AngularSitecoreConfig; + +const serverConfig = { + defaultSite: 'site-a', + api: { edge: { contextId: 'server-ctx', edgeUrl: 'https://edge.example.com' } }, +} as unknown as AngularSitecoreConfig; + +const pageViewData: PageViewData = { page: 'Home', language: 'en', pageVariantId: 'v1' }; +const eventData: EventData = { type: 'myretailsite:CLICKED_PROMO' }; +const identityData: IdentityData = { identifiers: [{ id: 'guest-1', provider: 'crm' }] }; + +beforeEach(() => { + vi.clearAllMocks(); + // clearAllMocks keeps implementations; restore async resolutions + dev-mode default explicitly. + mocks.initContentSdk.mockResolvedValue(undefined); + mocks.pageView.mockResolvedValue(undefined); + mocks.event.mockResolvedValue(undefined); + mocks.identity.mockResolvedValue(undefined); + mocks.form.mockResolvedValue(undefined); + TestBed.resetTestingModule(); +}); + +describe('normalizeCookieDomain', () => { + it('strips the port', () => { + expect(normalizeCookieDomain('example.com:3000')).toBe('example.com'); + }); + + it('strips a leading www. (case-insensitive)', () => { + expect(normalizeCookieDomain('www.example.com')).toBe('example.com'); + expect(normalizeCookieDomain('WWW.Example.com')).toBe('Example.com'); + }); + + it('strips both port and leading www.', () => { + expect(normalizeCookieDomain('www.example.com:8080')).toBe('example.com'); + }); + + it('leaves a bare domain unchanged', () => { + expect(normalizeCookieDomain('example.com')).toBe('example.com'); + expect(normalizeCookieDomain('localhost')).toBe('localhost'); + }); + + it('only strips a leading www., not www in the middle', () => { + expect(normalizeCookieDomain('app.www.example.com')).toBe('app.www.example.com'); + }); +}); + +describe('SITECORE_ANALYTICS injection (provideSitecoreAngular)', () => { + function injectAnalytics(platform: 'browser' | 'server'): SitecoreAnalyticsWrapper { + TestBed.configureTestingModule({ + providers: [provideSitecoreAngular({}), { provide: PLATFORM_ID, useValue: platform }], + }); + return TestBed.inject(SITECORE_ANALYTICS); + } + + it('provides the browser implementation on the browser platform', () => { + expect(injectAnalytics('browser')).toBeInstanceOf(SitecoreAnalyticsBrowser); + }); + + it('provides the server implementation on the server platform', () => { + expect(injectAnalytics('server')).toBeInstanceOf(SitecoreAnalyticsServer); + }); +}); + +describe('SitecoreAnalyticsBrowser', () => { + function create(config: AngularSitecoreConfig | null = browserConfig) { + TestBed.configureTestingModule({ + providers: [ + SitecoreAnalyticsBrowser, + { provide: SITECORE_CONFIG_TOKEN, useValue: config }, + ], + }); + return TestBed.inject(SitecoreAnalyticsBrowser); + } + + describe('dispatch when enabled (prod + clientContextId present)', () => { + it('initializes the SDK once and forwards pageView', async () => { + const svc = create(); + await svc.pageView(pageViewData); + + expect(mocks.initContentSdk).toHaveBeenCalledTimes(1); + expect(mocks.initContentSdk).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ contextId: 'client-ctx', siteName: 'site-a' }), + }) + ); + expect(mocks.analyticsBrowserAdapter).toHaveBeenCalledTimes(1); + expect(mocks.pageView).toHaveBeenCalledWith(pageViewData); + }); + + it('forwards event with its data', async () => { + await create().event(eventData); + expect(mocks.event).toHaveBeenCalledWith(eventData); + }); + + it('forwards identity with its data', async () => { + await create().identity(identityData); + expect(mocks.identity).toHaveBeenCalledWith(identityData); + }); + + it('forwards form with its arguments', async () => { + await create().form('form-1', 'SUBMITTED', 'cmp-9'); + expect(mocks.form).toHaveBeenCalledWith('form-1', 'SUBMITTED', 'cmp-9'); + }); + + it('initializes only once across multiple dispatches (memoized)', async () => { + const svc = create(); + await svc.event(eventData); + await svc.identity(identityData); + await svc.form('f', 'VIEWED', 'c'); + expect(mocks.initContentSdk).toHaveBeenCalledTimes(1); + }); + + it('passes a normalized cookie domain derived from the hostname', async () => { + await create().event(eventData); + const expected = normalizeCookieDomain(window.location.hostname); + expect(mocks.analyticsPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ enableCookie: true, cookieDomain: expected }), + }) + ); + }); + }); + + describe('pageView dedup', () => { + it('drops an identical page view within the dedup window', async () => { + const svc = create(); + await svc.pageView(pageViewData); + await svc.pageView({ ...pageViewData }); + expect(mocks.pageView).toHaveBeenCalledTimes(1); + }); + + it('dispatches again for a different fingerprint', async () => { + const svc = create(); + await svc.pageView(pageViewData); + await svc.pageView({ ...pageViewData, pageVariantId: 'v2' }); + expect(mocks.pageView).toHaveBeenCalledTimes(2); + }); + + it('dispatches again once the dedup window has elapsed', async () => { + let now = 10_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const svc = create(); + await svc.pageView(pageViewData); + now += 1_001; + await svc.pageView({ ...pageViewData }); + expect(mocks.pageView).toHaveBeenCalledTimes(2); + } finally { + nowSpy.mockRestore(); + } + }); + }); + + describe('guards (disabled paths)', () => { + it('does not dispatch when browser initialization is disabled', async () => { + const doInitSpy = vi + .spyOn( + SitecoreAnalyticsBrowser.prototype as unknown as { + doInit: () => Promise; + }, + 'doInit' + ) + .mockResolvedValue(false); + try { + await create().pageView(pageViewData); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + expect(mocks.pageView).not.toHaveBeenCalled(); + } finally { + doInitSpy.mockRestore(); + } + }); + + it('does not initialize or dispatch when clientContextId is missing', async () => { + const noEdge = { defaultSite: 'site-a', api: { edge: {} } } as unknown as AngularSitecoreConfig; + await create(noEdge).pageView(pageViewData); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + expect(mocks.pageView).not.toHaveBeenCalled(); + }); + + it('does not throw when config is absent', async () => { + await expect(create(null).event(eventData)).resolves.toBeUndefined(); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + }); + }); + + describe('error swallowing', () => { + it('resolves (does not reject) when the events call throws', async () => { + mocks.event.mockRejectedValueOnce(new Error('events boom')); + await expect(create().event(eventData)).resolves.toBeUndefined(); + }); + + it('resolves (does not reject) when init throws', async () => { + mocks.initContentSdk.mockRejectedValueOnce(new Error('init boom')); + await expect(create().pageView(pageViewData)).resolves.toBeUndefined(); + expect(mocks.pageView).not.toHaveBeenCalled(); + }); + }); +}); + +describe('SitecoreAnalyticsServer', () => { + const ssrContext = ( + headers: Record = { host: 'www.example.com:3000' }, + withRes = true + ) => ({ req: { headers }, ...(withRes ? { res: {} } : {}) }); + + const ABSENT = Symbol('absent'); + + function create( + config: AngularSitecoreConfig | null = serverConfig, + ssr: unknown = ssrContext() + ) { + TestBed.configureTestingModule({ + providers: [ + SitecoreAnalyticsServer, + { provide: SITECORE_CONFIG_TOKEN, useValue: config }, + // Omit the provider entirely to model an absent REQUEST_CONTEXT (optional inject → null). + ...(ssr === ABSENT ? [] : [{ provide: REQUEST_CONTEXT, useValue: ssr }]), + ], + }); + return TestBed.inject(SitecoreAnalyticsServer); + } + + describe('dispatch when enabled (contextId + req/res present)', () => { + it('initializes with the server adapter once and forwards pageView', async () => { + const svc = create(); + await svc.pageView(pageViewData); + + expect(mocks.initContentSdk).toHaveBeenCalledTimes(1); + expect(mocks.initContentSdk).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ contextId: 'server-ctx', siteName: 'site-a' }), + }) + ); + expect(mocks.analyticsServerAdapter).toHaveBeenCalledTimes(1); + expect(mocks.pageView).toHaveBeenCalledWith(pageViewData); + }); + + it('forwards event, identity and form', async () => { + const svc = create(); + await svc.event(eventData); + await svc.identity(identityData); + await svc.form('f1', 'VIEWED', 'c1'); + expect(mocks.event).toHaveBeenCalledWith(eventData); + expect(mocks.identity).toHaveBeenCalledWith(identityData); + expect(mocks.form).toHaveBeenCalledWith('f1', 'VIEWED', 'c1'); + expect(mocks.initContentSdk).toHaveBeenCalledTimes(1); + }); + + it('does not dedup repeated page views (server has no dedup window)', async () => { + const svc = create(); + await svc.pageView(pageViewData); + await svc.pageView({ ...pageViewData }); + expect(mocks.pageView).toHaveBeenCalledTimes(2); + }); + + it('derives the cookie domain from x-forwarded-host (port + www stripped)', async () => { + const svc = create(serverConfig, ssrContext({ 'x-forwarded-host': 'www.forwarded.com:8443' })); + await svc.event(eventData); + expect(mocks.analyticsPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ cookieDomain: 'forwarded.com' }), + }) + ); + }); + + it('prefers x-forwarded-host over host and reads the first array entry', async () => { + const svc = create( + serverConfig, + ssrContext({ 'x-forwarded-host': ['proxy.example.com'], host: 'origin.example.com' }) + ); + await svc.event(eventData); + expect(mocks.analyticsPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ cookieDomain: 'proxy.example.com' }), + }) + ); + }); + + it('passes the Node req/res to the server adapter', async () => { + const ssr = ssrContext({ host: 'example.com' }); + await create(serverConfig, ssr).event(eventData); + expect(mocks.analyticsServerAdapter).toHaveBeenCalledWith(ssr.req, ssr.res); + }); + }); + + describe('guards (disabled paths)', () => { + it('does not initialize when contextId is missing', async () => { + const noEdge = { defaultSite: 'site-a', api: { edge: {} } } as unknown as AngularSitecoreConfig; + await create(noEdge).pageView(pageViewData); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + expect(mocks.pageView).not.toHaveBeenCalled(); + }); + + it('does not initialize when REQUEST_CONTEXT is absent', async () => { + await create(serverConfig, ABSENT).pageView(pageViewData); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + }); + + it('does not initialize when res is missing from the context', async () => { + await create(serverConfig, ssrContext({ host: 'example.com' }, false)).pageView(pageViewData); + expect(mocks.initContentSdk).not.toHaveBeenCalled(); + }); + }); + + describe('error swallowing', () => { + it('resolves when the events call throws', async () => { + mocks.identity.mockRejectedValueOnce(new Error('boom')); + await expect(create().identity(identityData)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/angular/src/lib/analytics/sitecore-analytics.ts b/packages/angular/src/lib/analytics/sitecore-analytics.ts new file mode 100644 index 0000000000..2d7c25740b --- /dev/null +++ b/packages/angular/src/lib/analytics/sitecore-analytics.ts @@ -0,0 +1,46 @@ +import { InjectionToken } from '@angular/core'; +import type { EventData, IdentityData, PageViewData } from '@sitecore-content-sdk/events'; + +/** + * Browser-only analytics façade for Sitecore CDP events. A single injectable token backed by + * two platform implementations: the browser implementation lazily initializes the Content SDK + * events runtime on first use and dispatches; the server implementation is a no-op so callers + * never need platform guards. Resolve it with `inject(SITECORE_ANALYTICS)`. + * @public + */ +export interface SitecoreAnalyticsWrapper { + /** Dispatch a page view event. */ + pageView(data: PageViewData): Promise; + /** Dispatch a custom event. */ + event(data: EventData): Promise; + /** Dispatch an identity event. */ + identity(data: IdentityData): Promise; + /** Dispatch a form interaction event. */ + form( + formId: string, + interactionType: 'VIEWED' | 'SUBMITTED', + componentInstanceId: string + ): Promise; +} + +/** + * DI token for the {@link SitecoreAnalyticsWrapper} façade. `provideSitecoreAngular` registers a + * browser or server implementation based on the current platform. + * @public + */ +export const SITECORE_ANALYTICS = new InjectionToken( + 'SITECORE_ANALYTICS' +); + +/** + * Normalize a hostname into a cookie domain: strips the port and a leading `www.` so that + * browser-side and server-side cookie writes resolve to the same domain. Divergent + * normalization would mint duplicate visitor cookies on `www.` hosts. + * @param {string} hostname - Hostname, optionally with a port (e.g. `www.example.com:3000`). + * @returns {string} Normalized cookie domain (e.g. `example.com`). + * @public + */ +export function normalizeCookieDomain(hostname: string): string { + const withoutPort = hostname.split(':')[0]; + return withoutPort.replace(/^www\./i, ''); +} \ No newline at end of file diff --git a/packages/angular/src/lib/providers.ts b/packages/angular/src/lib/providers.ts index 991f1608bb..0734e58b54 100644 --- a/packages/angular/src/lib/providers.ts +++ b/packages/angular/src/lib/providers.ts @@ -1,11 +1,21 @@ import type { SitecoreClient } from '@sitecore-content-sdk/content/client'; -import { EnvironmentProviders, makeEnvironmentProviders, Provider } from '@angular/core'; +import { + EnvironmentProviders, + makeEnvironmentProviders, + Provider, + PLATFORM_ID, + inject, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { SITECORE_CONFIG_TOKEN, SITECORE_CLIENT_TOKEN, ERROR_ROUTE_TOKEN, NOT_FOUND_ROUTE_TOKEN, } from './tokens'; +import { SITECORE_ANALYTICS } from './analytics/sitecore-analytics'; +import { SitecoreAnalyticsBrowser } from './analytics/sitecore-analytics.browser'; +import { SitecoreAnalyticsServer } from './analytics/sitecore-analytics.server'; import type { AngularSitecoreConfig } from '../config/define-config'; /** @@ -66,5 +76,15 @@ export function provideSitecoreAngular(init: AngularCSDKAppInit): EnvironmentPro providers.push({ provide: ERROR_ROUTE_TOKEN, useValue: init.errorRoute }); } + // Browser-only analytics façade. The server implementation is a no-op, so consumers + // (e.g. CdpPageView) can inject SITECORE_ANALYTICS without platform guards. + providers.push({ + provide: SITECORE_ANALYTICS, + useFactory: () => + isPlatformBrowser(inject(PLATFORM_ID)) + ? new SitecoreAnalyticsBrowser() + : new SitecoreAnalyticsServer(), + }); + return makeEnvironmentProviders(providers as Parameters[0]); } diff --git a/packages/angular/src/lib/sitecore-context.service.ts b/packages/angular/src/lib/sitecore-context.service.ts index dffd73e878..4dce433d8a 100644 --- a/packages/angular/src/lib/sitecore-context.service.ts +++ b/packages/angular/src/lib/sitecore-context.service.ts @@ -47,6 +47,8 @@ export class SitecoreContextService { ); /** Whether the current page is in editing mode. */ readonly isEditing: Signal = computed(() => this.page()?.mode?.isEditing ?? false); + /** Whether the current page is in preview mode. */ + readonly isPreview: Signal = computed(() => this.page()?.mode?.isPreview ?? false); /** * Locale extracted from the current URL; `null` when no configured-locale prefix * or when locales are not configured. diff --git a/packages/angular/src/loaders/client-loader-data.service.spec.ts b/packages/angular/src/loaders/client-loader-data.service.spec.ts index 366a8a5e27..590f9d9bd4 100644 --- a/packages/angular/src/loaders/client-loader-data.service.spec.ts +++ b/packages/angular/src/loaders/client-loader-data.service.spec.ts @@ -8,6 +8,7 @@ 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'; +import { makeLoaderPayload } from '../testing/loader-spec-helpers'; describe('ClientLoaderDataService', () => { let service: ClientLoaderDataService; @@ -48,7 +49,7 @@ describe('ClientLoaderDataService', () => { describe('getData', () => { it('should make new data request when no pending requests and no staged prefetched response', async () => { setupTestBed(); - const request = { url: '/test', loaderId: 'page' }; + const request = makeLoaderPayload({ url: '/test' }); const resultPromise = service.getData(request); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); @@ -56,7 +57,7 @@ describe('ClientLoaderDataService', () => { expect(req.request.body).toEqual({ loaderId: 'page', url: '/test', - params: {}, + routeParams: {}, query: {}, }); req.flush({ kind: 'data', data: { title: 'Hello' } }); @@ -67,7 +68,7 @@ describe('ClientLoaderDataService', () => { it('should return pending request if request for data is already pending', async () => { setupTestBed(); - const request = { url: '/pending', loaderId: 'page' }; + const request = makeLoaderPayload({ url: '/pending' }); const promise1 = service.getData(request); const promise2 = service.getData(request); @@ -81,7 +82,7 @@ describe('ClientLoaderDataService', () => { it('should return error when requested in server context (not browser)', async () => { setupTestBed({ platformId: 'server' }); - const result = await service.getData({ url: '/any', loaderId: 'page' }); + const result = await service.getData(makeLoaderPayload({ url: '/any' })); expect(result).toEqual({ kind: 'error', status: 500, @@ -94,7 +95,7 @@ describe('ClientLoaderDataService', () => { describe('prefetch', () => { it('should stage prefetched response without consuming so getData can read it without a new request', async () => { setupTestBed(); - const request = { url: '/prefetched', loaderId: 'page' }; + const request = makeLoaderPayload({ url: '/prefetched' }); service.prefetch(request); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush({ kind: 'data', data: { prefetched: true } }); @@ -107,13 +108,13 @@ describe('ClientLoaderDataService', () => { it('should no-op on server', () => { setupTestBed({ platformId: 'server' }); - service.prefetch({ url: '/any', loaderId: 'page' }); + service.prefetch(makeLoaderPayload({ url: '/any' })); httpController.expectNone(LOADER_DATA_ENDPOINT); }); it('should not make a new request when prefetched response is already staged', async () => { setupTestBed(); - const request = { url: '/staged', loaderId: 'page' }; + const request = makeLoaderPayload({ url: '/staged' }); service.prefetch(request); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush({ kind: 'data', data: { staged: true } }); @@ -125,7 +126,7 @@ describe('ClientLoaderDataService', () => { it('should not start a second request when one is already pending', () => { setupTestBed(); - const request = { url: '/pending', loaderId: 'page' }; + const request = makeLoaderPayload({ url: '/pending' }); service.prefetch(request); service.prefetch(request); @@ -138,7 +139,7 @@ describe('ClientLoaderDataService', () => { it('should use custom data endpoint when provided in DI', async () => { const customEndpoint = '/api/loader-data'; setupTestBed({ fetchDataEndpoint: customEndpoint }); - const resultPromise = service.getData({ url: '/test', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/test' })); const req = httpController.expectOne(customEndpoint); expect(req.request.method).toBe('POST'); @@ -149,7 +150,7 @@ describe('ClientLoaderDataService', () => { it('should use default data endpoint when fetchDataEndpoint not provided in DI', async () => { setupTestBed(); - const resultPromise = service.getData({ url: '/test', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/test' })); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush({ kind: 'data', data: {} }); @@ -158,7 +159,7 @@ describe('ClientLoaderDataService', () => { it('should use default data endpoint when fetchDataEndpoint is null in DI', async () => { setupTestBed({ fetchDataEndpoint: null }); - const resultPromise = service.getData({ url: '/test', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/test' })); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush({ kind: 'data', data: {} }); @@ -167,7 +168,7 @@ describe('ClientLoaderDataService', () => { it('should return error when fetch promise fails', async () => { setupTestBed(); - const resultPromise = service.getData({ url: '/fail', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/fail' })); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.error(new ProgressEvent('error')); @@ -179,8 +180,8 @@ describe('ClientLoaderDataService', () => { it('should add pending request', async () => { setupTestBed(); - const promise1 = service.getData({ url: '/same', loaderId: 'page' }); - const promise2 = service.getData({ url: '/same', loaderId: 'page' }); + const promise1 = service.getData(makeLoaderPayload({ url: '/same' })); + const promise2 = service.getData(makeLoaderPayload({ url: '/same' })); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush({ kind: 'data', data: { one: 1 } }); @@ -194,7 +195,7 @@ describe('ClientLoaderDataService', () => { describe('fetchData error response (no response body)', () => { it('should return error with endpoint in message when response is falsy', async () => { setupTestBed(); - const resultPromise = service.getData({ url: '/empty', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/empty' })); const req = httpController.expectOne(LOADER_DATA_ENDPOINT); req.flush(null); @@ -209,7 +210,7 @@ describe('ClientLoaderDataService', () => { it('should return error with custom endpoint in message when custom endpoint and falsy response', async () => { const customEndpoint = '/custom/data'; setupTestBed({ fetchDataEndpoint: customEndpoint }); - const resultPromise = service.getData({ url: '/empty', loaderId: 'page' }); + const resultPromise = service.getData(makeLoaderPayload({ url: '/empty' })); const req = httpController.expectOne(customEndpoint); req.flush(null); diff --git a/packages/angular/src/loaders/client-loader-data.service.ts b/packages/angular/src/loaders/client-loader-data.service.ts index 60ddab566a..f7b0748ae4 100644 --- a/packages/angular/src/loaders/client-loader-data.service.ts +++ b/packages/angular/src/loaders/client-loader-data.service.ts @@ -2,8 +2,7 @@ import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { firstValueFrom } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { Params } from '@angular/router'; -import { LoaderApiRequest, LoaderApiResponse, LoaderCacheConfig } from './models'; +import { LoaderPayload, LoaderApiResponse } from './models'; import { LOADER_DATA_ENDPOINT } from '../server/constants'; import { FETCH_DATA_ENDPOINT } from './loader-registry.token'; @@ -17,23 +16,6 @@ function requestKey(loaderId: string, url: string): string { return `loader:${loaderId}:${url}`; } -/** - * Request parameters for fetching loader data - * @public - */ -export interface LoaderDataRequest { - url: string; - 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. @@ -56,9 +38,9 @@ export class ClientLoaderDataService { * 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 + * @param {LoaderPayload} loaderRequest - The loader data request */ - prefetch(loaderRequest: LoaderDataRequest): void { + prefetch(loaderRequest: LoaderPayload): void { if (!isPlatformBrowser(this.platformId)) { return; } @@ -78,10 +60,10 @@ export class ClientLoaderDataService { * If a request is already pending for this URL/loader combination, * waits for it to complete instead of making a duplicate request. * Consumes (removes) staged responses after retrieval. - * @param {LoaderDataRequest} request - The loader data request + * @param {LoaderPayload} request - The loader data request * @returns {Promise} Promise resolving to the API response */ - async getData(request: LoaderDataRequest): Promise { + async getData(request: LoaderPayload): Promise { if (!isPlatformBrowser(this.platformId)) { return { kind: 'error', @@ -114,16 +96,16 @@ export class ClientLoaderDataService { * Fetch data from the configured data endpoint. * Callers (getData, prefetch) add the returned promise to pending; it is removed * in finally when the promise settles. - * @param {LoaderDataRequest} request - The loader data request + * @param {LoaderPayload} request - The loader data request * @returns {Promise} Promise resolving to the API response */ - private async fetchData(request: LoaderDataRequest): Promise { + private async fetchData(request: LoaderPayload): Promise { const key = requestKey(request.loaderId, request.url); const endpoint = this.fetchDataEndpoint; - const reqBody: LoaderApiRequest = { + const reqBody: LoaderPayload = { loaderId: request.loaderId, url: request.url, - params: request.params ?? {}, + routeParams: request.routeParams ?? {}, query: request.query ?? {}, cacheOptions: request.cacheOptions, }; diff --git a/packages/angular/src/loaders/constants.ts b/packages/angular/src/loaders/constants.ts new file mode 100644 index 0000000000..162d414f62 --- /dev/null +++ b/packages/angular/src/loaders/constants.ts @@ -0,0 +1,9 @@ +/** + * Request header carrying the middleware-resolved Content SDK request params + * (site name, variant ids) from Express middlewares to the loader pipeline. + * Custom Express request properties don't survive Angular's conversion of the + * incoming request to a web `Request` (the SSR `REQUEST` token), so the params + * also ride this header — the same mechanism `x-sitecore-editing-params` uses. + * @public + */ +export const SC_PARAMS_HEADER = 'x-sitecore-params'; diff --git a/packages/angular/src/loaders/context-helpers.ts b/packages/angular/src/loaders/context-helpers.ts new file mode 100644 index 0000000000..72bcbd0e0f --- /dev/null +++ b/packages/angular/src/loaders/context-helpers.ts @@ -0,0 +1,50 @@ +import type { LoaderContext } from './models'; +import { DEFAULT_VARIANT } from '@sitecore-content-sdk/content/personalize'; + +/** + * Read the site name resolved for the current request (multisite middleware → + * `scParams`, with the configured default site applied by the server loader runner). + * @param {LoaderContext} context - Loader context. + * @returns {string} Site name for the current request. + * @public + */ +export function getSiteName(context: LoaderContext): string { + return context.scParams.siteName; +} + +/** + * Read the page-level personalization variant id resolved for the current request + * (personalize middleware → `scParams`). Returns the default variant id when the + * request was not personalized. + * @param {LoaderContext} context - Loader context. + * @returns {string} Page-level variant id. + * @public + */ +export function getVariantId(context: LoaderContext): string { + return context.scParams.variantId ?? DEFAULT_VARIANT; +} + +/** + * Read the component A/B test variant ids resolved for the current request + * (personalize middleware → `scParams`). Empty when the request was not personalized. + * @param {LoaderContext} context - Loader context. + * @returns {string[]} Component-level variant ids. + * @public + */ +export function getComponentVariantIds(context: LoaderContext): string[] { + return context.scParams.componentVariantIds ?? []; +} + +/** + * Read the language for the current request from the matched route params + * (`scLocaleMatcher` exposes the locale URL segment as `routeParams.locale`). + * Returns `undefined` when no locale was matched — fall back to your + * configured default language. + * @param {LoaderContext} context - Loader context. + * @returns {string | undefined} Language for the current request. + * @public + */ +export function getLanguage(context: LoaderContext): string | undefined { + const locale = context.routeParams.locale; + return typeof locale === 'string' ? locale : undefined; +} diff --git a/packages/angular/src/loaders/index.ts b/packages/angular/src/loaders/index.ts index be44f0dda1..33665e5d8a 100644 --- a/packages/angular/src/loaders/index.ts +++ b/packages/angular/src/loaders/index.ts @@ -2,23 +2,22 @@ export * from './loader-resolver'; export * from './loader-registry.token'; export * from './client-loader-data.service'; export * from './pre-loader-data.service'; -export { - SERVER_LOADER_RUNNER, - type ServerLoaderRunnerPort, -} from './server-loader-runner.token'; +export { SERVER_LOADER_RUNNER, type ServerLoaderRunnerPort } from './server-loader-runner.token'; export { NotFoundNavigationError, LoaderHttpError, type LoaderFn, type LoaderContext, type LoaderDataResult, - type LoaderApiRequest, + type LoaderPayload, + type LoaderRunnerInit, type LoaderApiResponse, type LoaderRedirectResult, type PerRouteLoaderCacheConfig, type LoaderCacheConfig, type LoaderCache, - type RequestContext, + type CsdkRequestData, + type CsdkRequestParams, type LoaderCacheEntryInfo, type LoaderCacheReadResult, type LoaderCacheEntry, @@ -26,3 +25,9 @@ export { } from './models'; export { handleNavigationError, redirectOnNavigationError } from './router-error-handling'; export { applyRedirect } from './utils'; +export { + getSiteName, + getVariantId, + getComponentVariantIds, + getLanguage, +} from './context-helpers'; diff --git a/packages/angular/src/loaders/loader-resolver.spec.ts b/packages/angular/src/loaders/loader-resolver.spec.ts index 6eecb883fb..ef493ee2d6 100644 --- a/packages/angular/src/loaders/loader-resolver.spec.ts +++ b/packages/angular/src/loaders/loader-resolver.spec.ts @@ -15,6 +15,9 @@ import type { LoaderFn } from './models'; import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; import type { AngularSitecoreConfig } from '../config/define-config'; +import { mockAngularSitecoreConfig, makeLoaderPayload } from '../testing/loader-spec-helpers'; + +const mockSitecoreConfig = mockAngularSitecoreConfig(); function makeRouteSnapshot( overrides: Partial<{ @@ -49,6 +52,7 @@ describe('loaderResolver', () => { { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, { provide: ClientLoaderDataService, useValue: mockLoaderData }, + { provide: SITECORE_CONFIG_TOKEN, useValue: mockSitecoreConfig }, ], }); transferState = TestBed.inject(TransferState); @@ -95,8 +99,9 @@ describe('loaderResolver', () => { expect(mockLoaderData.getData).toHaveBeenCalledWith({ url: '/page/123', loaderId: 'page', - params: { id: '123' }, + routeParams: { id: '123', locale: 'en' }, query: { q: 'search' }, + cacheOptions: undefined, }); expect(result).toEqual({ title: 'Home' }); }); @@ -173,6 +178,7 @@ describe('loaderResolver', () => { ClientLoaderDataService, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: LOADER_REGISTRY, useValue: { page: (async () => ({})) as LoaderFn } }, + { provide: SITECORE_CONFIG_TOKEN, useValue: mockSitecoreConfig }, ], }); httpController = TestBed.inject(HttpTestingController); @@ -238,7 +244,7 @@ describe('loaderResolver', () => { const route = makeRouteSnapshot({ pathFromRoot: [{ params: {} }] }); const state = makeRouterStateSnapshot('/after-settle'); - loaderData.prefetch({ loaderId: 'page', url: '/after-settle' }); + loaderData.prefetch(makeLoaderPayload({ url: '/after-settle' })); const req0 = httpController.expectOne(LOADER_DATA_ENDPOINT); req0.flush({ kind: 'data', data: { prefetched: true } }); await new Promise((r) => setTimeout(r, 0)); @@ -278,6 +284,7 @@ describe('loaderResolver', () => { { provide: PLATFORM_ID, useValue: 'server' }, { provide: LOADER_REGISTRY, useValue: { page: mockLoader } }, { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, + { provide: SITECORE_CONFIG_TOKEN, useValue: mockSitecoreConfig }, provideServerLoaderRunner(), ], }); @@ -302,9 +309,12 @@ describe('loaderResolver', () => { expect(mockLoader).toHaveBeenCalledTimes(1); expect(mockLoader).toHaveBeenCalledWith({ url: '/about', - params: { slug: 'about' }, + routeParams: { slug: 'about', locale: 'en' }, query: { lang: 'en' }, - requestContext: undefined, + scParams: { + siteName: 'default', + }, + csdkRequestData: {}, }); expect(result).toEqual({ server: true, title: 'SSR' }); }); @@ -371,12 +381,16 @@ describe('loaderResolver', () => { { provide: LOADER_REGISTRY, useValue: { page: cachedLoader } }, { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST_CONTEXT, useValue: { cache } }, + { + provide: SITECORE_CONFIG_TOKEN, + useValue: mockAngularSitecoreConfig({ defaultSite: 'demo' }), + }, provideServerLoaderRunner(), ], }); const resolver = loaderResolver('page'); - const route = makeRouteSnapshot({ pathFromRoot: [{ params: { site: 'demo' } }] }); + const route = makeRouteSnapshot({ pathFromRoot: [{ params: { locale: 'en' } }] }); const state = makeRouterStateSnapshot('/cached-ssr'); await TestBed.runInInjectionContext(async () => { @@ -413,12 +427,13 @@ describe('loaderResolver', () => { { provide: LOADER_REGISTRY, useValue: { page: loaderWithRequest } }, { provide: ClientLoaderDataService, useValue: { getData: vi.fn() } }, { provide: REQUEST, useValue: mockRequest }, + { provide: SITECORE_CONFIG_TOKEN, useValue: mockSitecoreConfig }, provideServerLoaderRunner(), ], }); }); - it('should pass requestContext to loader', async () => { + it('should pass csdkRequestData to loader', async () => { const resolver = loaderResolver('page'); const route = makeRouteSnapshot(); const state = makeRouterStateSnapshot('/path'); @@ -432,13 +447,13 @@ describe('loaderResolver', () => { expect(loaderWithRequest).toHaveBeenCalledWith( expect.objectContaining({ url: '/path', - requestContext: expect.any(Object), + csdkRequestData: expect.any(Object), }) ); const call = (loaderWithRequest as ReturnType).mock.calls[0][0]; - expect(call.requestContext).toBeDefined(); - expect(call.requestContext?.hostname).toBe('example.com'); - expect(call.requestContext?.query?.foo).toBe('bar'); + expect(call.csdkRequestData).toBeDefined(); + expect(call.csdkRequestData?.hostname).toBe('example.com'); + expect(call.csdkRequestData?.query?.foo).toBe('bar'); }); }); @@ -488,7 +503,7 @@ describe('loaderResolver', () => { }); expect(mockLoader).toHaveBeenCalledWith( - expect.objectContaining({ params: expect.objectContaining({ locale: 'de' }) }) + expect.objectContaining({ routeParams: expect.objectContaining({ locale: 'de' }) }) ); }); @@ -504,7 +519,7 @@ describe('loaderResolver', () => { }); expect(mockLoader).toHaveBeenCalledWith( - expect.objectContaining({ params: expect.objectContaining({ locale: 'en' }) }) + expect.objectContaining({ routeParams: expect.objectContaining({ locale: 'en' }) }) ); }); }); diff --git a/packages/angular/src/loaders/loader-resolver.ts b/packages/angular/src/loaders/loader-resolver.ts index 3a20de2518..5a094c8829 100644 --- a/packages/angular/src/loaders/loader-resolver.ts +++ b/packages/angular/src/loaders/loader-resolver.ts @@ -10,7 +10,7 @@ import { } from '@angular/router'; import { LOADER_ID } from './loader-registry.token'; import { ClientLoaderDataService } from './client-loader-data.service'; -import { extractRequestContext, applyRedirect } from './utils'; +import { extractRequestData, applyRedirect } from './utils'; import { EDITING_PARAMS_HEADER } from '../editing/constants'; import { DEFAULT_ERROR_ROUTE, @@ -23,6 +23,7 @@ import { redirectOnNavigationError } from './router-error-handling'; 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 @@ -58,7 +59,7 @@ export type LoaderId = keyof LoaderIdMap extends never ? string : keyof LoaderId * @param {string} [defaultLanguage] - Default language to fall back to. * @returns {Params} Merged params with a guaranteed `locale` when `defaultLanguage` is set. */ -function buildLoaderParams(route: ActivatedRouteSnapshot, defaultLanguage?: string): Params { +function buildLoaderParams(route: ActivatedRouteSnapshot, defaultLanguage: string): Params { const merged = route.pathFromRoot.reduce((acc, r) => ({ ...acc, ...r.params }), {} as Params); if (!merged.locale && defaultLanguage) { merged.locale = defaultLanguage; @@ -90,7 +91,7 @@ async function resolveOnBrowser({ state: RouterStateSnapshot; loaderId: string; router: Router; - defaultLanguage?: string; + defaultLanguage: string; cacheOptions?: PerRouteLoaderCacheConfig; }): Promise { const transferState = inject(TransferState); @@ -110,7 +111,7 @@ async function resolveOnBrowser({ const resp = await browserLoaderData.getData({ url, loaderId, - params: allParams, + routeParams: allParams, query: route.queryParams as Record, cacheOptions, }); @@ -145,7 +146,8 @@ export const loaderResolver = ( inject(NOT_FOUND_ROUTE_TOKEN, { optional: true }) || DEFAULT_NOT_FOUND_ROUTE; const errorRoute = inject(ERROR_ROUTE_TOKEN, { optional: true }) || DEFAULT_ERROR_ROUTE; const router = inject(Router); - const defaultLanguage = inject(SITECORE_CONFIG_TOKEN, { optional: true })?.defaultLanguage; + const config = inject(SITECORE_CONFIG_TOKEN); + const defaultLanguage = config.defaultLanguage; const url = state.url; const key = stateKey(loaderId, url); @@ -173,15 +175,13 @@ export const loaderResolver = ( ); } - const angularRequestContext = request ? extractRequestContext(request) : undefined; + const csdkRequestData = request ? extractRequestData(request) : {}; // When the request flows through createEditingRenderMiddleware, the // editing payload is propagated via x-sitecore-editing-params. Cached // responses must not be served to the editor (it expects fresh layout // every render), so disable cache for this resolution. - const isEditingRequest = Boolean( - angularRequestContext?.headers?.[EDITING_PARAMS_HEADER] - ); + const isEditingRequest = !!csdkRequestData.headers?.[EDITING_PARAMS_HEADER]; const effectiveCacheOptions = isEditingRequest ? { ...(cacheOptions ?? {}), enabled: false } : cacheOptions; @@ -189,10 +189,10 @@ export const loaderResolver = ( const result = await serverLoaderRunner.resolve({ loaderId, url, - params: buildLoaderParams(route, defaultLanguage), + routeParams: buildLoaderParams(route, defaultLanguage), query: route.queryParams as Record, - angularRequestContext, cacheOptions: effectiveCacheOptions, + csdkRequestData, }); if (result.kind === 'redirect') { diff --git a/packages/angular/src/loaders/models.ts b/packages/angular/src/loaders/models.ts index bd99146b3c..05ed6c5979 100644 --- a/packages/angular/src/loaders/models.ts +++ b/packages/angular/src/loaders/models.ts @@ -1,13 +1,26 @@ import type { Params } from '@angular/router'; +import { EditingPreviewData } from '@sitecore-content-sdk/content/editing'; export const DEFAULT_NOT_FOUND_ROUTE = '/404'; export const DEFAULT_ERROR_ROUTE = '/500'; /** - * Request context containing information from the incoming HTTP request. + * Content SDK request params like site name, variant ids + * @public + */ +export interface CsdkRequestParams { + /** Site name. Resolved from the request hostname */ + siteName?: string; + /** Variant id. Either resovled from route or set to default variant id name */ + variantId?: string; + /** Component variant IDs */ + componentVariantIds?: string[]; +} +/** + * Request data from the incoming HTTP request. * Used for request-dependent operations in loaders. * @public */ -export interface RequestContext { +export interface CsdkRequestData { /** * The hostname from the request (without port) */ @@ -24,6 +37,16 @@ export interface RequestContext { * Headers from the request */ headers?: Record; + /** + * Referrer from the request + */ + referrer?: string; + /** + * Preview/editing data for Content SDK + */ + scPreviewData?: EditingPreviewData; + /** Content SDK request params */ + scParams?: CsdkRequestParams; } /** @@ -44,7 +67,7 @@ export type LoaderContext = { * `defaultLanguage` from `sitecore.config` when no locale segment was matched — loaders * can rely on a concrete `params.locale` regardless of URL shape. */ - params: Params; + routeParams: Params; /** * Query string parameters */ @@ -53,50 +76,51 @@ export type LoaderContext = { * Server-only: the incoming request */ req?: Request; + /** Content SDK request params like site name, variant ids */ + scParams: Omit & { siteName: string }; /** - * Server-only: the response object - */ - res?: Response; - /** - * Server-only: context from the incoming HTTP request. - * Contains hostname, cookies, query params, and headers. - * Use with createSiteResolver() to determine the current site. - * @example - * ```typescript - * const resolveSite = createSiteResolver({ sites, defaultSite: config.defaultSite }); - * - * export const pageLoader: LoaderFn = async (ctx) => { - * if (ctx.requestContext) { - * const { site } = resolveSite(ctx.requestContext); - * return client.getPage(ctx.url, { site: site.name }); - * } - * return client.getPage(ctx.url); - * }; - * ``` + * Server-only: request data extracted from the incoming HTTP request + * (hostname, headers, cookies, editing preview data). Absent during prerender. */ - requestContext?: RequestContext; + csdkRequestData?: CsdkRequestData; }; -export type LoaderApiRequest = { +/** + * Payload for loader resolution. + * @public + */ +export type LoaderPayload = { + /** + * The loader ID + */ loaderId: string; + /** + * The requst URL + */ 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. + * The ANgular request route parameters */ - angularRequestContext?: RequestContext; + routeParams: Params; + /** + * The request query parameters + */ + query: Record; /** * 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. + * per-route policy applies on CSR navigations. */ cacheOptions?: LoaderCacheConfig; }; +export type LoaderRunnerInit = LoaderPayload & { + /** + * Supplemental Content SDK request data + */ + csdkRequestData: CsdkRequestData | null; +}; + export type LoaderRedirectResult = { loaderRedirectTarget: string; status?: number; @@ -204,7 +228,7 @@ export interface LoaderCacheEntryInfo { } /** - * Three-outcome read result for stale-while-revalidate (Phase 3). + * Three-outcome read result for stale-while-revalidate * * - `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. 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 b60eb1e252..af605803c8 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.spec.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.spec.ts @@ -83,13 +83,13 @@ describe('ClientPreLoaderDataService', () => { expect(loaderDataPrefetchSpy).toHaveBeenCalledWith({ loaderId: 'layout', url: '/page/123', - params: {}, + routeParams: {}, query: {}, }); expect(loaderDataPrefetchSpy).toHaveBeenCalledWith({ loaderId: 'page', url: '/page/123', - params: { id: '123' }, + routeParams: { id: '123' }, query: { q: 'search' }, }); }); @@ -119,7 +119,7 @@ describe('ClientPreLoaderDataService', () => { expect(loaderDataPrefetchSpy).toHaveBeenCalledWith({ loaderId: 'page', url: '/page', - params: {}, + routeParams: {}, query: {}, }); }); diff --git a/packages/angular/src/loaders/pre-loader-data.service.ts b/packages/angular/src/loaders/pre-loader-data.service.ts index 4a32c91611..737f598aba 100644 --- a/packages/angular/src/loaders/pre-loader-data.service.ts +++ b/packages/angular/src/loaders/pre-loader-data.service.ts @@ -10,7 +10,7 @@ import { RouterStateSnapshot, } from '@angular/router'; import { ClientLoaderDataService } from './client-loader-data.service'; -import { LoaderDataRequest } from './client-loader-data.service'; +import { LoaderPayload } from './models'; import { LOADER_ID } from './loader-registry.token'; /** @@ -83,8 +83,8 @@ export class ClientPreLoaderDataService { private collectLoaders( route: ActivatedRouteSnapshot, state: RouterStateSnapshot - ): LoaderDataRequest[] { - const loaderDataRequests: LoaderDataRequest[] = []; + ): LoaderPayload[] { + const loaderDataRequests: LoaderPayload[] = []; const breadcrump = route.pathFromRoot ?? []; for (const route of breadcrump) { @@ -99,14 +99,14 @@ export class ClientPreLoaderDataService { ) { const loaderId = (resolver as ResolverWithLoaderId)[LOADER_ID]; const url = state.url; - const params: Params = (route.pathFromRoot ?? []).reduce( + const routeParams: Params = (route.pathFromRoot ?? []).reduce( (acc, r) => ({ ...acc, ...(r?.params ?? {}) }), {} ); loaderDataRequests.push({ loaderId, url, - params, + routeParams, query: (route.queryParams ?? {}) as Record, }); } diff --git a/packages/angular/src/loaders/server-loader-runner.token.ts b/packages/angular/src/loaders/server-loader-runner.token.ts index cacd1090d8..cb3a21e0b8 100644 --- a/packages/angular/src/loaders/server-loader-runner.token.ts +++ b/packages/angular/src/loaders/server-loader-runner.token.ts @@ -1,5 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { LoaderApiRequest, LoaderDataResult } from './models'; +import { LoaderRunnerInit, LoaderDataResult } from './models'; /** * SSR injection port for cache-aware loader resolution. @@ -10,10 +10,10 @@ import { LoaderApiRequest, LoaderDataResult } from './models'; export interface ServerLoaderRunnerPort { /** * Resolve loader data on the server (cache-aware) using the shared {@link LOADER_REGISTRY}. - * @param {LoaderApiRequest} request - Loader request payload + * @param {LoaderRunnerInit} init - Loader request payload * @returns {Promise} Resolved loader result */ - resolve(request: LoaderApiRequest): Promise; + resolve(init: LoaderRunnerInit): Promise; } /** diff --git a/packages/angular/src/loaders/utils.spec.ts b/packages/angular/src/loaders/utils.spec.ts index 2db0e0fd6f..54f32ebe02 100644 --- a/packages/angular/src/loaders/utils.spec.ts +++ b/packages/angular/src/loaders/utils.spec.ts @@ -1,6 +1,9 @@ import { RedirectCommand, Router } from '@angular/router'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { applyRedirect, extractRequestContext } from './utils'; +import { DEFAULT_VARIANT } from '@sitecore-content-sdk/content/personalize'; +import { EDITING_PARAMS_HEADER } from '../editing/constants'; +import { SC_PARAMS_HEADER } from './constants'; +import { applyRedirect, extractRequestData } from './utils'; describe('applyRedirect', () => { let mockRouter: { parseUrl: ReturnType }; @@ -20,10 +23,11 @@ describe('applyRedirect', () => { it('returns void and calls window.location.assign for external URL', () => { const assignSpy = vi.fn(); const originalWindow = globalThis.window; - (globalThis as unknown as { window: { location: { assign: ReturnType } } }).window = - { - location: { assign: assignSpy }, - }; + ( + globalThis as unknown as { window: { location: { assign: ReturnType } } } + ).window = { + location: { assign: assignSpy }, + }; const result = applyRedirect(mockRouter as unknown as Router, 'https://example.com/path'); expect(result).toBeUndefined(); @@ -36,10 +40,11 @@ describe('applyRedirect', () => { it('treats http URL as external', () => { const assignSpy = vi.fn(); const originalWindow = globalThis.window; - (globalThis as unknown as { window: { location: { assign: ReturnType } } }).window = - { - location: { assign: assignSpy }, - }; + ( + globalThis as unknown as { window: { location: { assign: ReturnType } } } + ).window = { + location: { assign: assignSpy }, + }; const result = applyRedirect(mockRouter as unknown as Router, 'http://example.com'); expect(result).toBeUndefined(); @@ -60,7 +65,7 @@ describe('applyRedirect', () => { }); }); -describe('extractRequestContext', () => { +describe('extractRequestData', () => { it('extracts hostname, headers, cookies, and query from Fetch API Request', () => { const req = new Request('https://example.com:8080/path?foo=bar&baz=qux&foo=dup', { headers: { @@ -68,7 +73,7 @@ describe('extractRequestContext', () => { cookie: 'session=abc123; theme=dark', }, }); - const ctx = extractRequestContext(req); + const ctx = extractRequestData(req); expect(ctx.hostname).toBe('example.com'); expect(ctx.headers).toEqual( expect.objectContaining({ @@ -85,7 +90,7 @@ describe('extractRequestContext', () => { it('returns empty cookies when Request has no cookie header', () => { const req = new Request('https://example.com/', { headers: {} }); - const ctx = extractRequestContext(req); + const ctx = extractRequestData(req); expect(ctx.cookies).toEqual({}); }); @@ -95,16 +100,67 @@ describe('extractRequestContext', () => { cookies: { a: '1', b: '2' }, query: { page: '1', sort: 'asc' }, }; - const ctx = extractRequestContext(expressReq); + const ctx = extractRequestData(expressReq); expect(ctx.headers).toEqual({ 'x-custom': 'value', cookie: 'a=1' }); expect(ctx.cookies).toEqual({ a: '1', b: '2' }); expect(ctx.query).toEqual({ page: '1', sort: 'asc' }); }); it('handles Express-like request with minimal fields', () => { - const ctx = extractRequestContext({}); - expect(ctx.headers).toBeUndefined(); - expect(ctx.cookies).toBeUndefined(); - expect(ctx.query).toBeUndefined(); + const ctx = extractRequestData({}); + expect(ctx.headers).toEqual({}); + expect(ctx.cookies).toEqual({}); + expect(ctx.query).toEqual({}); + }); + + it('reads scParams from SC_PARAMS_HEADER on Fetch Request', () => { + const req = new Request('https://example.com/about', { + headers: { + [SC_PARAMS_HEADER]: JSON.stringify({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }), + }, + }); + + expect(extractRequestData(req).scParams).toEqual({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }); + }); + + it('reads scParams from SC_PARAMS_HEADER on Express-like request', () => { + const ctx = extractRequestData({ + headers: { + [SC_PARAMS_HEADER]: JSON.stringify({ siteName: 'from-header', variantId: DEFAULT_VARIANT }), + }, + }); + expect(ctx.scParams).toEqual({ siteName: 'from-header', variantId: DEFAULT_VARIANT }); + }); + + it('prefers req.scParams over SC_PARAMS_HEADER when both are present', () => { + const ctx = extractRequestData({ + scParams: { siteName: 'direct', variantId: DEFAULT_VARIANT }, + headers: { + [SC_PARAMS_HEADER]: JSON.stringify({ siteName: 'from-header', variantId: DEFAULT_VARIANT }), + }, + }); + expect(ctx.scParams?.siteName).toBe('direct'); + }); + + it('parses editing preview data from EDITING_PARAMS_HEADER', () => { + const preview = { itemId: 'item-1', language: 'en', version: 1, mode: 'preview' }; + const ctx = extractRequestData({ + headers: { [EDITING_PARAMS_HEADER]: JSON.stringify(preview) }, + }); + expect(ctx.scPreviewData).toEqual(preview); + }); + + it('derives hostname from host header on Express-like requests', () => { + expect(extractRequestData({ headers: { host: 'fallback.example.com:8080' } }).hostname).toBe( + 'fallback.example.com' + ); }); }); diff --git a/packages/angular/src/loaders/utils.ts b/packages/angular/src/loaders/utils.ts index 7828eae8a9..2dd34d8c7c 100644 --- a/packages/angular/src/loaders/utils.ts +++ b/packages/angular/src/loaders/utils.ts @@ -1,6 +1,10 @@ import { RedirectCommand } from '@angular/router'; import type { Router } from '@angular/router'; -import { RequestContext } from './models'; +import { CsdkRequestData, CsdkRequestParams } from './models'; +import { EDITING_PARAMS_HEADER } from '../server/middleware'; +import { EditingPreviewData } from '@sitecore-content-sdk/content/editing'; +import { SC_PARAMS_HEADER } from './constants'; +import debug from '../debug'; /** * Apply a redirect: internal URLs → RedirectCommand; external URLs → full page navigation. @@ -24,12 +28,17 @@ export function applyRedirect(router: Router, location: string): RedirectCommand } /** - * Express-like request object interface + * Express-like request object interface with Content SDK request params */ interface ExpressLikeRequest { headers?: Record; cookies?: Record; query?: Record; + scParams?: CsdkRequestParams; +} + +interface FetchLikeRequest extends Request { + scParams?: CsdkRequestParams; } /** @@ -96,48 +105,68 @@ function parseCookieHeader(cookieHeader: string | null): Record * @returns {RequestContext} The request context * @example * ```typescript - * import { extractRequestContext } from '@sitecore-content-sdk/angular/server'; + * import { extractRequestData } from '@sitecore-content-sdk/angular/server'; * * // From Express request - * const requestContext = extractRequestContext(expressReq); + * const requestContext = extractRequestData(expressReq); * * // From Fetch API Request (Angular's REQUEST token) - * const requestContext = extractRequestContext(request); + * const requestContext = extractRequestData(request); * ``` * @public */ -export function extractRequestContext(req: Request | ExpressLikeRequest): RequestContext { - // Check if it's a Fetch API Request object +export function extractRequestData(req: FetchLikeRequest | ExpressLikeRequest): CsdkRequestData { + let hostname: string | undefined; + let headers: Record = {}; + let cookies: Record = {}; + let query: Record = {}; + let scPreviewData: EditingPreviewData | undefined; + let scParams = 'scParams' in req ? req.scParams : undefined; + // Check if it's a Fetch API Request object - we're in browser loaders flow in this case if (req instanceof Request) { - const headers = headersToObject(req.headers); - const cookies = parseCookieHeader(req.headers.get('cookie')); - const query = parseQueryFromUrl(req.url); - - // Extract hostname from URL - let hostname: string | undefined; + headers = headersToObject(req.headers); + cookies = parseCookieHeader(req.headers.get('cookie')); + query = parseQueryFromUrl(req.url); try { hostname = new URL(req.url).hostname; } catch { - // URL parsing failed, hostname will be resolved from headers + // empty catch - resolve hostname from host header later } + } else { + headers = req.headers ?? {}; + cookies = req.cookies ?? {}; + query = req.query ?? {}; + } + hostname = + hostname ?? + pickHostnameFromHostHeader(Array.isArray(headers?.host) ? headers?.host[0] : headers?.host); - return { - hostname, - headers, - cookies, - query, - }; + const scPreviewDataHeader = headers?.[EDITING_PARAMS_HEADER] as string; + if (scPreviewDataHeader) { + try { + scPreviewData = JSON.parse(scPreviewDataHeader) as EditingPreviewData; + } catch { + debug.editing('Failed to parse editing preview data from header'); + } } - const hostHeader = req.headers?.host; - const hostname = pickHostnameFromHostHeader( - Array.isArray(hostHeader) ? hostHeader[0] : hostHeader - ); + // Express request properties don't survive Angular's conversion to a web Request, + // so middleware-resolved params also ride the SC_PARAMS_HEADER (set alongside req.scParams). + const scParamsHeader = headers?.[SC_PARAMS_HEADER] as string; + if (!scParams && scParamsHeader) { + try { + scParams = JSON.parse(scParamsHeader) as CsdkRequestParams; + } catch { + debug.common('Failed to parse Content SDK request params from header'); + } + } return { hostname, - headers: req.headers, - cookies: req.cookies, - query: req.query, + headers, + cookies, + query, + scPreviewData, + scParams, }; } diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index a64db8d845..1e4bd11436 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -2,11 +2,6 @@ export { type AngularCSDKAppInit } from './lib/providers'; export { SITECORE_CONFIG_TOKEN, SITECORE_CLIENT_TOKEN } from './lib/tokens'; -export { - defineConfig, - type AngularSitecoreConfigInput, - type AngularSitecoreConfig, -} from './config/define-config'; export { getContentStylesheetLink, getDesignLibraryStylesheetLinks, @@ -88,12 +83,29 @@ export { type LoaderFn, type LoaderContext, type LoaderDataResult, + type LoaderPayload, + type LoaderRunnerInit, + type CsdkRequestData, + type CsdkRequestParams, type PerRouteLoaderCacheConfig, type LoaderCacheConfig, } from './loaders/models'; export { handleNavigationError } from './loaders/router-error-handling'; export { applyRedirect } from './loaders/utils'; +export { + getSiteName, + getVariantId, + getComponentVariantIds, + getLanguage, +} from './loaders/context-helpers'; +export { SC_PARAMS_HEADER } from './loaders/constants'; export { provideSitecoreAngular } from './lib/providers'; +export { + SITECORE_ANALYTICS, + normalizeCookieDomain, + type SitecoreAnalyticsWrapper, +} from './lib/analytics/sitecore-analytics'; +export { CdpHelper } from '@sitecore-content-sdk/content/personalize'; export * from './server'; // ─── Sitecore Context ────────────────────────────────────────── diff --git a/packages/angular/src/server/cache/cache-key.spec.ts b/packages/angular/src/server/cache/cache-key.spec.ts index 13ef47c2fd..02467d9abc 100644 --- a/packages/angular/src/server/cache/cache-key.spec.ts +++ b/packages/angular/src/server/cache/cache-key.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { describe, it, expect } from 'vitest'; -import type { LoaderContext } from '../../loaders/models'; +import { DEFAULT_VARIANT } from '@sitecore-content-sdk/content/personalize'; import { buildCacheKey, buildPageCacheKey, @@ -9,50 +9,76 @@ import { serializeLoaderCacheKey, } from './cache-key'; import type { CacheKeyDimensions } from './models'; +import { makeLoaderContext } from '../../testing/loader-spec-helpers'; +import type { CsdkRequestParams } from '../../loaders/models'; -function makeContext(overrides: Partial = {}): LoaderContext { +const setDefaultScParams = (scParams: Partial) => { return { - url: '/about', - params: { site: 'mysite', locale: 'en' }, - query: {}, - ...overrides, + componentVariantIds: [], + siteName: 'default', + variantId: DEFAULT_VARIANT, + ...scParams, }; -} +}; describe('buildCacheKey', () => { it('builds sc:loader:page key from site, locale, variant, and pathKey', () => { - const { key, dimensions } = buildCacheKey('page', makeContext({ url: '/about?preview=1' })); + const { key, dimensions } = buildCacheKey( + 'page', + makeLoaderContext({ + url: '/about?preview=1', + scParams: setDefaultScParams({ siteName: 'mysite' }), + }) + ); expect(dimensions).toEqual({ site: 'mysite', locale: 'en', - variantId: 'default', + variantId: DEFAULT_VARIANT, loaderId: 'page', pathKey: 'about', + componentVariantIds: [], }); - expect(key).toBe('sc:loader:page:mysite:en:default:about'); + expect(key).toBe(`sc:loader:page:mysite:en:${DEFAULT_VARIANT}:about`); }); it('uses _ pathKey for home route', () => { - const { dimensions } = buildCacheKey('page', makeContext({ url: '/' })); + const { dimensions } = buildCacheKey( + 'page', + makeLoaderContext({ url: '/', scParams: setDefaultScParams({ siteName: 'mysite' }) }) + ); 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' } }) + makeLoaderContext({ + url: '/en/about', + routeParams: { locale: 'en' }, + scParams: setDefaultScParams({ siteName: 'mysite' }), + }) ); expect(dimensions.pathKey).toBe('about'); }); it('builds dictionary key without variant or path', () => { - const { key } = buildCacheKey('dictionary', makeContext()); + const { key } = buildCacheKey( + 'dictionary', + makeLoaderContext({ scParams: setDefaultScParams({ siteName: 'mysite' }) }) + ); 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' })); + const { dimensions } = buildCacheKey( + 'page', + makeLoaderContext({ + routeParams: {}, + url: '/home', + scParams: setDefaultScParams({ siteName: 'default' }), + }) + ); expect(dimensions.site).toBe('default'); expect(dimensions.locale).toBe('en'); expect(dimensions.pathKey).toBe('home'); @@ -64,20 +90,20 @@ describe('serializeLoaderCacheKey', () => { const pageDims: CacheKeyDimensions = { site: 'demo', locale: 'de', - variantId: 'default', + variantId: DEFAULT_VARIANT, loaderId: 'page', pathKey: 'products/shoes', }; const dictDims: CacheKeyDimensions = { site: 'demo', locale: 'de', - variantId: 'default', + variantId: DEFAULT_VARIANT, loaderId: 'dictionary', pathKey: '_', }; expect(buildPageCacheKey(pageDims)).toBe( - `${CACHE_KEY_PREFIX}:page:demo:de:default:products/shoes` + `${CACHE_KEY_PREFIX}:page:demo:de:${DEFAULT_VARIANT}: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 index 823be603d0..58321ad966 100644 --- a/packages/angular/src/server/cache/cache-key.ts +++ b/packages/angular/src/server/cache/cache-key.ts @@ -55,8 +55,10 @@ export function serializeLoaderCacheKey(dimensions: CacheKeyDimensions): string 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}`; + const variantIdSection = dimensions.variantId + ? `:${sanitizeSitecoreCacheSegment(dimensions.variantId)}` + : ''; + return `${CACHE_KEY_PREFIX}:page:${site}:${locale}${variantIdSection}:${dimensions.pathKey}`; } /** @@ -82,6 +84,8 @@ export function buildGenericLoaderCacheKey(dimensions: CacheKeyDimensions): stri 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}`; + const variantIdSection = dimensions.variantId + ? `:${sanitizeSitecoreCacheSegment(dimensions.variantId)}` + : ''; + return `${CACHE_KEY_PREFIX}:${loaderId}:${site}:${locale}${variantIdSection}:${dimensions.pathKey}`; } diff --git a/packages/angular/src/server/cache/cache-tags.ts b/packages/angular/src/server/cache/cache-tags.ts index caadb44c1b..0a16f99176 100644 --- a/packages/angular/src/server/cache/cache-tags.ts +++ b/packages/angular/src/server/cache/cache-tags.ts @@ -8,7 +8,7 @@ import { import type { CacheKeyDimensions } from './models'; /** - * Sitecore OSR namespace prefix shared with Next.js (`sc:`). + * Sitecore OSR namespace prefix shared with other frameworks (`sc:`). * All loader cache keys and invalidation tags use this prefix. * @internal */ @@ -77,7 +77,7 @@ export type BuildLoaderDictionaryCacheTagsFromSitesParams = { }; /** - * Builds a Next.js-compatible dictionary tag: `sc:dict::`. + * Builds a dictionary cache 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. @@ -173,7 +173,7 @@ export function buildSitecoreLocaleCacheTag(locale: string): string { } /** - * Builds the full tag set written alongside a loader cache entry (Phase 3 OSR alignment). + * Builds the full tag set written alongside a loader cache entry. * 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. diff --git a/packages/angular/src/server/cache/cache.spec-helpers.ts b/packages/angular/src/server/cache/cache.spec-helpers.ts index 12c9ead165..fc49ad3a65 100644 --- a/packages/angular/src/server/cache/cache.spec-helpers.ts +++ b/packages/angular/src/server/cache/cache.spec-helpers.ts @@ -3,11 +3,13 @@ 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'; +import { mockScParams } from '../../testing/loader-spec-helpers'; export const sampleContext: LoaderContext = { url: '/products', - params: { site: 'shop', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + scParams: mockScParams({ siteName: 'shop' }), }; export function sampleKey(loaderId = 'page'): string { 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 index 3caba0d4b4..b89fcf4a61 100644 --- a/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.spec.ts @@ -4,7 +4,8 @@ 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'; +import type { ExpressRequest, ExpressResponse } from '../../middleware/models'; +import { makeLoaderContext, mockScParams } from '../../../testing/loader-spec-helpers'; function createMockRes() { return { @@ -27,12 +28,14 @@ describe('createCacheAdminMiddleware', () => { beforeEach(async () => { cache = createLoaderCache({ revalidate: 300, defaultSiteName: 'demo' }); - const ctx = { - url: '/about', - params: { site: 'demo', locale: 'en' }, - query: {}, - }; - const built = buildCacheKey('page', ctx); + const built = buildCacheKey( + 'page', + makeLoaderContext({ + url: '/about', + routeParams: { locale: 'en' }, + scParams: mockScParams({ siteName: 'demo' }), + }) + ); cacheKey = built.key; await cache.set( cacheKey, diff --git a/packages/angular/src/server/cache/demo/cache-admin-middleware.ts b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts index cc8c1e930c..b436c3bd76 100644 --- a/packages/angular/src/server/cache/demo/cache-admin-middleware.ts +++ b/packages/angular/src/server/cache/demo/cache-admin-middleware.ts @@ -8,7 +8,7 @@ import { ExpressNextFunction, ExpressRequest, ExpressResponse, -} from '../../models'; +} from '../../middleware/models'; import { InvalidateInput, LoaderCache } from '../../../loaders/models'; /** diff --git a/packages/angular/src/server/cache/loader-cache.ts b/packages/angular/src/server/cache/loader-cache.ts index 8c3c77fa06..0b5e7ba8f3 100644 --- a/packages/angular/src/server/cache/loader-cache.ts +++ b/packages/angular/src/server/cache/loader-cache.ts @@ -11,7 +11,7 @@ import memoryDriver from 'unstorage/drivers/memory'; * 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. + * @returns {LoaderCache} Cache implementation with SWR + tag semantics. * @example * ```ts * const cache = createLoaderCache({ diff --git a/packages/angular/src/server/cache/models.ts b/packages/angular/src/server/cache/models.ts index fcb93ec03c..99ede01569 100644 --- a/packages/angular/src/server/cache/models.ts +++ b/packages/angular/src/server/cache/models.ts @@ -9,16 +9,18 @@ export const DEFAULT_CACHE_TTL = 300; * @internal */ export interface CacheKeyDimensions { - /** Site name from route params (defaults to `'default'`). */ + /** Site name from route params (defaults to sitecoreConfig.defaultSite). */ 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; + /** Personalization variant segment (defaults to `'default'`). */ + variantId?: string; + /** Component variant ids from request context */ + componentVariantIds?: string[]; } /** diff --git a/packages/angular/src/server/cache/utils.spec.ts b/packages/angular/src/server/cache/utils.spec.ts index f2404a7507..ee1e835393 100644 --- a/packages/angular/src/server/cache/utils.spec.ts +++ b/packages/angular/src/server/cache/utils.spec.ts @@ -12,6 +12,7 @@ import { dedupeCacheStrings, } from './utils'; import { DEFAULT_CACHE_TTL } from './models'; +import { mockScParams } from '../../testing/loader-spec-helpers'; describe('urlToPathKey', () => { it('sanitizes path segments and uses _ for home', () => { @@ -29,14 +30,14 @@ 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' }, + routeParams: { locale: 'de' }, query: {}, + scParams: mockScParams({ siteName: 'blog' }), }); expect(dimensions).toEqual({ site: 'blog', locale: 'de', - variantId: 'default', loaderId: 'page', pathKey: 'articles/1', }); @@ -45,8 +46,9 @@ describe('dimensionsFromContext', () => { it('falls back to default site, locale, and home pathKey', () => { const dimensions = dimensionsFromContext('page', { url: '', - params: {}, + routeParams: {}, query: {}, + scParams: mockScParams({ siteName: 'default' }), }); expect(dimensions.site).toBe('default'); diff --git a/packages/angular/src/server/cache/utils.ts b/packages/angular/src/server/cache/utils.ts index 39f04f03c6..9c7d241337 100644 --- a/packages/angular/src/server/cache/utils.ts +++ b/packages/angular/src/server/cache/utils.ts @@ -68,15 +68,18 @@ export function urlToPathKey(url: string, locale?: string): string { * @internal */ export function dimensionsFromContext(loaderId: string, ctx: LoaderContext): CacheKeyDimensions { - const params = (ctx.params ?? {}) as Record; - const site = (params?.site as string) || 'default'; + const params = (ctx.routeParams ?? {}) as Record; + const site = ctx.scParams.siteName; + const variantId = ctx.scParams.variantId; + const componentVariantIds = ctx.scParams.componentVariantIds; const locale = (params?.locale as string) || 'en'; const pathKey = urlToPathKey(ctx.url || '/', locale); return { site, locale, - variantId: 'default', + variantId, + componentVariantIds, loaderId, pathKey, }; @@ -114,7 +117,7 @@ export function applyLoaderCacheConfigDefaults( } /** - * Maps a stored entry to the three-outcome read result used by {@link ServerLoaderRunner} (Phase 3 SWR). + * Maps a stored entry to the three-outcome read result used by {@link ServerLoaderRunner}. * @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()`). diff --git a/packages/angular/src/server/config-cli/define-cli-config.spec.ts b/packages/angular/src/server/config-cli/define-cli-config.spec.ts index 5cb23c5b18..b674558a47 100644 --- a/packages/angular/src/server/config-cli/define-cli-config.spec.ts +++ b/packages/angular/src/server/config-cli/define-cli-config.spec.ts @@ -1,11 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as contentConfigCli from '@sitecore-content-sdk/content/config-cli'; -import type { - SitecoreCliConfig, - SitecoreCliConfigInput, -} from '@sitecore-content-sdk/content/config'; +import type { SitecoreCliConfig } from '@sitecore-content-sdk/content/config'; import { ComponentTemplateType } from '@sitecore-content-sdk/content/config'; -import { defineCliConfig } from './define-cli-config'; +import type { AngularSitecoreConfig } from '../../config/define-config'; +import { defineCliConfig, type AngularCsdkCliConfig } from './define-cli-config'; + +function createCliConfigInput( + overrides: Omit, 'config'> & { + config?: AngularSitecoreConfig; + } = {} +): AngularCsdkCliConfig { + return { + config: overrides.config ?? ({} as AngularSitecoreConfig), + ...overrides, + }; +} describe('defineCliConfig', () => { beforeEach(() => { @@ -19,14 +28,14 @@ describe('defineCliConfig', () => { }); it('should add a noop build command when build is not provided', () => { - const result = defineCliConfig({} as SitecoreCliConfigInput); + const result = defineCliConfig(createCliConfigInput()); expect(result.build?.commands).toHaveLength(1); expect(typeof result.build!.commands![0]).toBe('function'); }); it('should initialize scaffold with the Angular default component template when not provided', () => { - const result = defineCliConfig({} as SitecoreCliConfigInput); + const result = defineCliConfig(createCliConfigInput()); expect(result.scaffold.templates).toHaveLength(1); expect(result.scaffold.templates[0].name).toBe(ComponentTemplateType.DEFAULT); @@ -41,25 +50,29 @@ describe('defineCliConfig', () => { it('should keep existing scaffold templates when provided', () => { const existing = { name: 'custom', fileExtension: '.ts', generateTemplate: () => 'x' }; - const result = defineCliConfig({ - scaffold: { templates: [existing] }, - } as unknown as SitecoreCliConfigInput); + const result = defineCliConfig( + createCliConfigInput({ + scaffold: { templates: [existing] }, + }) + ); expect(result.scaffold.templates).toHaveLength(1); expect(result.scaffold.templates[0].name).toBe('custom'); }); it('should set default componentMap generator and paths', () => { - const result = defineCliConfig({} as SitecoreCliConfigInput); + const result = defineCliConfig(createCliConfigInput()); expect(typeof result.componentMap?.generator).toBe('function'); expect(result.componentMap?.paths).toEqual(['src/app/components']); }); it('should let user-provided componentMap values override defaults', () => { - const result = defineCliConfig({ - componentMap: { paths: ['src/custom'] }, - } as unknown as SitecoreCliConfigInput); + const result = defineCliConfig( + createCliConfigInput({ + componentMap: { paths: ['src/custom'] }, + }) + ); expect(result.componentMap?.paths).toEqual(['src/custom']); expect(typeof result.componentMap?.generator).toBe('function'); diff --git a/packages/angular/src/server/config-cli/define-cli-config.ts b/packages/angular/src/server/config-cli/define-cli-config.ts index 39755d8447..19661ccdd0 100644 --- a/packages/angular/src/server/config-cli/define-cli-config.ts +++ b/packages/angular/src/server/config-cli/define-cli-config.ts @@ -4,10 +4,32 @@ import type { } from '@sitecore-content-sdk/content/config'; import { defineCliConfig as defineCliConfigCore } from '@sitecore-content-sdk/content/config-cli'; import { generateMap } from '../tools/generate-map'; - +import { AngularSitecoreConfig } from '../../config/define-config'; +import { generateSites as generateSitesContent } from '@sitecore-content-sdk/content/node-tools'; +import type { SitecoreConfig } from '@sitecore-content-sdk/content/config'; const noopBuildCommand = async () => {}; -export type AngularCsdkCliConfig = Omit; +export const generateSites = ({ destinationPath }: { destinationPath?: string } = {}): ((args: { + scConfig: AngularSitecoreConfig; +}) => Promise) => { + return async ({ scConfig }: { scConfig: AngularSitecoreConfig }) => { + const convertedConfig = scConfig as SitecoreConfig; + await generateSitesContent({ destinationPath })({ scConfig: convertedConfig }); + }; +}; + +/** + * CLI configuration input for Angular apps. Narrows {@link SitecoreCliConfigInput} + * so `config` is the resolved {@link AngularSitecoreConfig} from `sitecore.config.ts` + * and build commands receive the same type in `scConfig`. + * @public + */ +export type AngularCsdkCliConfig = Omit & { + config: AngularSitecoreConfig; + build?: { + commands?: Array<(args?: { scConfig: AngularSitecoreConfig }) => Promise>; + }; +}; /** * Ensures `build.commands` exists so {@link defineCliConfigCore} validation passes. @@ -24,7 +46,7 @@ function addDefaultBuildCommands(cliConfig: AngularCsdkCliConfig) { /** * Minimal default scaffold entry so `sitecore-tools project scaffold` remains usable. - * @param {SitecoreCliConfigInput} cliConfig - CLI configuration being built up + * @param {AngularCsdkCliConfig} cliConfig - CLI configuration being built up */ function addDefaultScaffoldTemplates(cliConfig: AngularCsdkCliConfig) { if (!cliConfig.scaffold) { @@ -49,8 +71,8 @@ export class ${componentName}Component {} } /** - * Registers the Angular component map generator (same CLI entrypoint as Next.js). - * @param {SitecoreCliConfigInput} cliConfig - CLI configuration being built up + * Registers the Angular component map generator (same CLI entrypoint as other frameworks). + * @param {AngularCsdkCliConfig} cliConfig - CLI configuration being built up */ function addDefaultComponentMapGenerator(cliConfig: AngularCsdkCliConfig) { cliConfig.componentMap = { @@ -61,9 +83,9 @@ function addDefaultComponentMapGenerator(cliConfig: AngularCsdkCliConfig) { } /** - * Accepts a {@link SitecoreCliConfigInput} and returns CLI configuration with Angular defaults + * Accepts an {@link AngularCsdkCliConfig} and returns CLI configuration with Angular defaults * (component map generator, optional build/scaffold placeholders), then applies core validation. - * @param {SitecoreCliConfigInput} cliConfig - CLI configuration from `sitecore.cli.config.ts` + * @param {AngularCsdkCliConfig} cliConfig - CLI configuration from `sitecore.cli.config.ts` * @returns Resolved {@link SitecoreCliConfig} * @public */ @@ -71,5 +93,8 @@ export const defineCliConfig = (cliConfig: AngularCsdkCliConfig): SitecoreCliCon addDefaultBuildCommands(cliConfig); addDefaultScaffoldTemplates(cliConfig); addDefaultComponentMapGenerator(cliConfig); - return defineCliConfigCore(cliConfig as SitecoreCliConfigInput); + return defineCliConfigCore( + // AngularSitecoreConfig is a structural superset; redirects.locales differs only in typing. + cliConfig as unknown as SitecoreCliConfigInput + ); }; diff --git a/packages/angular/src/server/config-cli/index.ts b/packages/angular/src/server/config-cli/index.ts index d97fd00922..efe8f48ff4 100644 --- a/packages/angular/src/server/config-cli/index.ts +++ b/packages/angular/src/server/config-cli/index.ts @@ -1,2 +1,3 @@ export { generateMetadata } from '@sitecore-content-sdk/core/node-tools'; -export { defineCliConfig, type AngularCsdkCliConfig } from './define-cli-config'; +export { defineCliConfig, type AngularCsdkCliConfig, generateSites } from './define-cli-config'; +export type { SitecoreCliConfig } from '@sitecore-content-sdk/content/config'; diff --git a/packages/angular/src/server/editing/get-editing-preview-data.ts b/packages/angular/src/server/editing/get-editing-preview-data.ts index 95aec43c36..9e02b10bd2 100644 --- a/packages/angular/src/server/editing/get-editing-preview-data.ts +++ b/packages/angular/src/server/editing/get-editing-preview-data.ts @@ -1,22 +1,22 @@ import type { EditingPreviewData } from '@sitecore-content-sdk/content/editing'; -import type { RequestContext } from '../../loaders/models'; +import type { CsdkRequestData } from '../../loaders/models'; import { EDITING_PARAMS_HEADER } from '../../editing/constants'; /** * Read the editing preview data from the request context produced by the - * SSR resolver (`extractRequestContext`). Returns `undefined` when the + * SSR resolver (`extractRequestData`). Returns `undefined` when the * request did not flow through {@link createEditingRenderMiddleware}. * * Loaders use this to branch between published-content fetching * (`client.getPage`) and preview fetching (`client.getPreview`). - * @param {RequestContext | undefined} requestContext - Loader request context. + * @param {CsdkRequestData | undefined} csdkRequestData - Loader request context. * @returns {EditingPreviewData | undefined} Parsed preview data or `undefined`. * @public */ export function getEditingPreviewData( - requestContext: RequestContext | undefined + csdkRequestData: CsdkRequestData | undefined ): EditingPreviewData | undefined { - const raw = requestContext?.headers?.[EDITING_PARAMS_HEADER]; + const raw = csdkRequestData?.headers?.[EDITING_PARAMS_HEADER]; const headerValue = Array.isArray(raw) ? raw[0] : raw; if (!headerValue) { return undefined; diff --git a/packages/angular/src/server/express/index.ts b/packages/angular/src/server/express/index.ts deleted file mode 100644 index 83b8537314..0000000000 --- a/packages/angular/src/server/express/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { LOADER_DATA_ENDPOINT } from '../constants'; -export { - ExpressDataHandlerOptions, - ExpressRequest, - ExpressResponse, - ExpressNextFunction, - ExpressMiddleware, - DataHandlerConfig, -} from '../models'; -export { ServerLoaderRunner } from '../server-loader-runner'; -export { provideServerLoaderRunner } from '../provide-server-loader-runner'; diff --git a/packages/angular/src/server/index.ts b/packages/angular/src/server/index.ts index eb31fca087..3c5d853609 100644 --- a/packages/angular/src/server/index.ts +++ b/packages/angular/src/server/index.ts @@ -1,16 +1,6 @@ // Configuration export { LOADER_DATA_ENDPOINT } from './constants'; -// Express handlers -export { - ExpressDataHandlerOptions, - ExpressRequest, - ExpressResponse, - ExpressNextFunction, - ExpressMiddleware, - DataHandlerConfig, -} from './models'; - export { ServerLoaderRunner } from './server-loader-runner'; export { provideServerLoaderRunner } from './provide-server-loader-runner'; @@ -21,3 +11,7 @@ export { getEditingPreviewData } from './editing/get-editing-preview-data'; // 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'; +/** + * @public + */ +export type { LoaderRegistry } from '../loaders/loader-registry.token'; diff --git a/packages/angular/src/server/middleware/editing-config-middleware.spec.ts b/packages/angular/src/server/middleware/editing-config-middleware.spec.ts index d5023150ab..4d7b02a1a0 100644 --- a/packages/angular/src/server/middleware/editing-config-middleware.spec.ts +++ b/packages/angular/src/server/middleware/editing-config-middleware.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Component } from '@angular/core'; import { EditMode } from '@sitecore-content-sdk/content/layout'; import { createEditingConfigMiddleware } from './editing-config-middleware'; -import type { ExpressRequest, ExpressResponse } from '../models'; +import type { ExpressRequest, ExpressResponse } from './models'; import type { ComponentMap } from '../../components/types'; @Component({ selector: 'test-a', template: '' }) diff --git a/packages/angular/src/server/middleware/editing-config-middleware.ts b/packages/angular/src/server/middleware/editing-config-middleware.ts index e1b59aac97..c3f61127cb 100644 --- a/packages/angular/src/server/middleware/editing-config-middleware.ts +++ b/packages/angular/src/server/middleware/editing-config-middleware.ts @@ -5,7 +5,7 @@ import { import { EditMode } from '@sitecore-content-sdk/content/layout'; import { getEnforcedCorsHeaders } from '@sitecore-content-sdk/core/tools'; import type { Metadata } from '@sitecore-content-sdk/core/node-tools'; -import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from './models'; import type { ComponentMap } from '../../components/types'; import { readProcessEnv } from '../utils'; import debug from '../../debug'; @@ -98,8 +98,7 @@ export function normalizeImportedMetadata(module: unknown): Metadata { /** * Express middleware that serves the editing config endpoint - * (default path: `/api/editing/config`). Mirrors the Next.js - * `EditingConfigMiddleware` and returns the registered component names, + * (default path: `/api/editing/config`). Returns the registered component names, * package versions, and the configured edit mode. * @param {CreateEditingConfigMiddlewareOptions} options - Middleware options. * @returns {ExpressMiddleware} The middleware function. diff --git a/packages/angular/src/server/middleware/editing-render-middleware.spec.ts b/packages/angular/src/server/middleware/editing-render-middleware.spec.ts index f3871cf7cd..6356329d0a 100644 --- a/packages/angular/src/server/middleware/editing-render-middleware.spec.ts +++ b/packages/angular/src/server/middleware/editing-render-middleware.spec.ts @@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createEditingRenderMiddleware, - EDITING_PARAMS_HEADER, type ExpressEditingRequest, } from './editing-render-middleware'; -import type { ExpressRequest, ExpressResponse } from '../models'; +import type { ExpressRequest, ExpressResponse } from './models'; +import { EDITING_PARAMS_HEADER } from '../../editing/constants'; function createMockRes() { return { diff --git a/packages/angular/src/server/middleware/editing-render-middleware.ts b/packages/angular/src/server/middleware/editing-render-middleware.ts index b4b1ec4498..c5caeb0bb5 100644 --- a/packages/angular/src/server/middleware/editing-render-middleware.ts +++ b/packages/angular/src/server/middleware/editing-render-middleware.ts @@ -5,14 +5,12 @@ import { } from '@sitecore-content-sdk/content/editing'; import { DEFAULT_VARIANT } from '@sitecore-content-sdk/content/personalize'; import { getAllowedOriginsFromEnv, getEnforcedCorsHeaders } from '@sitecore-content-sdk/core/tools'; -import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from './models'; import { readProcessEnv } from '../utils'; import { resolveConfiguredEditingSecret } from './editing-config-middleware'; import debug from '../../debug'; import { EDITING_PARAMS_HEADER } from '../../editing/constants'; -export { EDITING_PARAMS_HEADER } from '../../editing/constants'; - const DEFAULT_ENDPOINT = '/api/editing/render'; /** @@ -158,7 +156,7 @@ function buildCSPHeader(): string { * request, sets the CSP header, rewrites `req.url` to the target route, and * hands the request off to the Angular SSR pipeline via `next()`. * - * Unlike the Next.js port, no internal HTTP fetch is performed - the editing + * No internal HTTP fetch is performed - the editing * payload travels alongside the Express request through the existing * middleware chain. * @param {CreateEditingRenderMiddlewareOptions} [options] - Middleware options. diff --git a/packages/angular/src/server/middleware/index.ts b/packages/angular/src/server/middleware/index.ts index 0842d01dd4..8436ba460f 100644 --- a/packages/angular/src/server/middleware/index.ts +++ b/packages/angular/src/server/middleware/index.ts @@ -22,10 +22,27 @@ export { } from './editing-config-middleware'; export { createEditingRenderMiddleware, - EDITING_PARAMS_HEADER, type CreateEditingRenderMiddlewareOptions, type ExpressEditingRequest, type AllowedQueryParam, type AllowedQueryParams, type AllowedQueryParamsResolver, } from './editing-render-middleware'; +export { createMultisiteMiddleware, type MultisiteMiddlewareOptions } from './multisite-middleware'; +export { + createPersonalizeMiddleware, + type PersonalizeMiddlewareOptions, +} from './personalize-middleware'; +export { shouldProcessPath } from './utils'; +export { isEditingPreview } from '../utils'; +export type { PathPattern } from '../utils'; +export type { MiddlewareMatcher } from './models'; +export { EDITING_PARAMS_HEADER } from '../../editing/constants'; +// Express handlers +export { + ExpressRequest, + ExpressResponse, + ExpressNextFunction, + ExpressMiddleware, + BaseMiddlewareOptions, +} from './models'; diff --git a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts index 8cdb0e7414..5932b240af 100644 --- a/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.spec.ts @@ -1,13 +1,12 @@ /* 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 { createLoaderDataServiceMiddleware } from './loader-data-service-middleware'; 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'; +import { mockAngularSitecoreConfig } from '../../testing/loader-spec-helpers'; /** * Minimal Express `res` stub for middleware tests. @@ -30,12 +29,10 @@ function createMockNext() { describe('createLoaderDataServiceMiddleware', () => { const endpoint = LOADER_DATA_ENDPOINT; + const mockConfig = mockAngularSitecoreConfig({ defaultSite: 'demo' }); beforeEach(() => { vi.clearAllMocks(); - TestBed.configureTestingModule({ - providers: [{ provide: EXTRACT_REQUEST_CONTEXT_TOKEN, useValue: () => ({}) }], - }); }); /** eslint-disable jsdoc/require-jsdoc */ @@ -45,11 +42,10 @@ describe('createLoaderDataServiceMiddleware', () => { endpoint?: string; cache?: import('../../loaders/models').LoaderCache; }) { - const extractReq = TestBed.inject(EXTRACT_REQUEST_CONTEXT_TOKEN); - return createLoaderDataServiceMiddleware({ + return createLoaderDataServiceMiddleware(mockConfig, { ...opts, endpoint: opts.endpoint ?? endpoint, - extractRequestContext: extractReq, + cache: opts.cache ?? createLoaderCache({ revalidate: 300 }), }); } @@ -62,7 +58,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/', params: {}, query: {} }, + body: { loaderId: 'page', url: '/', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -74,9 +70,10 @@ describe('createLoaderDataServiceMiddleware', () => { expect(mockLoader).toHaveBeenCalledWith( expect.objectContaining({ url: '/', - params: {}, + routeParams: {}, query: {}, - requestContext: expect.any(Object), + scParams: { siteName: 'demo' }, + csdkRequestData: expect.any(Object), }) ); expect(res.json).toHaveBeenCalledWith({ @@ -106,8 +103,9 @@ describe('createLoaderDataServiceMiddleware', () => { expect(mockLoader).toHaveBeenCalledWith( expect.objectContaining({ url: '/about', - params: {}, + routeParams: {}, query: { q: 'search' }, + scParams: { siteName: 'demo' }, }) ); expect(res.json).toHaveBeenCalledWith({ @@ -155,7 +153,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/redirect-me', params: {}, query: {} }, + body: { loaderId: 'page', url: '/redirect-me', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -182,7 +180,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/list', params: {}, query: {} }, + body: { loaderId: 'page', url: '/list', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -207,7 +205,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/', params: {}, query: {} }, + body: { loaderId: 'page', url: '/', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -232,7 +230,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'page', url: '/missing', params: {}, query: {} }, + body: { loaderId: 'page', url: '/missing', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -255,7 +253,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { loaderId: 'unknownLoader', url: '/', params: {}, query: {} }, + body: { loaderId: 'unknownLoader', url: '/', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); @@ -305,7 +303,7 @@ describe('createLoaderDataServiceMiddleware', () => { body: { loaderId: 'page', url: '/cached-page', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, }, query: {}, @@ -320,14 +318,10 @@ describe('createLoaderDataServiceMiddleware', () => { expect(mockLoader).toHaveBeenCalledTimes(1); expect(setSpy).toHaveBeenCalledTimes(1); expect(setSpy).toHaveBeenCalledWith( - 'sc:loader:page:demo:en:default:cached-page', + 'sc:loader:page:demo:en:cached-page', { title: 'Cached page' }, 300, - expect.arrayContaining([ - 'sc:loader:page:demo:en:default:cached-page', - 'sc:site:demo', - 'sc:locale:en', - ]) + expect.arrayContaining(['sc:loader:page:demo:en:cached-page', 'sc:site:demo', 'sc:locale:en']) ); expect(res1.json).toHaveBeenCalledWith({ kind: 'data', @@ -348,7 +342,7 @@ describe('createLoaderDataServiceMiddleware', () => { const req = { method: 'POST', path: endpoint, - body: { url: '/', params: {}, query: {} }, + body: { url: '/', routeParams: {}, query: {} }, query: {}, }; const res = createMockRes(); diff --git a/packages/angular/src/server/middleware/loader-data-service-middleware.ts b/packages/angular/src/server/middleware/loader-data-service-middleware.ts index d1cbb10d92..a3112fe215 100644 --- a/packages/angular/src/server/middleware/loader-data-service-middleware.ts +++ b/packages/angular/src/server/middleware/loader-data-service-middleware.ts @@ -1,20 +1,42 @@ import { - LoaderApiRequest, LoaderApiResponse, NotFoundNavigationError, LoaderHttpError, LoaderDataResult, + LoaderCache, } from '../../loaders/models'; -import { extractRequestContext } from '../../loaders/utils'; import { - ExpressDataHandlerOptions, ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse, -} from '../models'; +} from './models'; import { LOADER_DATA_ENDPOINT } from '../constants'; import { ServerLoaderRunner } from '../server-loader-runner'; +import { parseLoaderRequest } from './utils'; +import { AngularSitecoreConfig } from '../../config/define-config'; +import { LoaderRegistry } from '../../loaders/loader-registry.token'; + +/** + * Options for the Express data handler + * @public + */ +export interface LoaderDataServiceOptions { + /** + * The shared loader registry (same object as provideLoaderRegistry). + */ + loaders: LoaderRegistry; + /** + * Optional loader cache. When supplied, /_data responses go through + * cache-aside; omit to run loaders directly on every request. + */ + cache: LoaderCache; + /** + * The endpoint path for the data handler. + * @default '/_data' + */ + endpoint?: string; +} /** * Map loader resolution result to wire-level API response. @@ -55,35 +77,6 @@ function sendResponse(res: ExpressResponse, result: LoaderApiResponse): void { res.json(result); } -/** - * Parse POST body or GET query into LoaderApiRequest, or return a validation error. - * @param {ExpressRequest} req - Incoming Express request - */ -function parseLoaderRequest( - req: ExpressRequest -): LoaderApiRequest | { status: number; message: string } { - if (req.method === 'POST') { - const body = req.body as LoaderApiRequest; - if (!body?.loaderId) return { status: 400, message: 'Missing loaderId' }; - return body; - } - if (req.method === 'GET') { - const loaderId = String(req.query?.loaderId ?? ''); - if (!loaderId) return { status: 400, message: 'Missing loaderId' }; - const query: Record = {}; - for (const [key, value] of Object.entries(req.query ?? {})) { - if (key !== 'loaderId' && key !== 'url' && typeof value === 'string') query[key] = value; - } - return { - loaderId, - url: String(req.query?.url ?? ''), - params: {}, - query, - }; - } - return { status: 405, message: 'Method not allowed' }; -} - /** * Create an Express middleware for the data endpoint. * This middleware handles both GET and POST requests at the configured endpoint path. @@ -91,7 +84,8 @@ function parseLoaderRequest( * The endpoint path must match the client: provide the same value to the Angular app via * FETCH_DATA_ENDPOINT (e.g. in app.config.ts). There is no Angular DI in Node/Express, * so you pass the endpoint here when calling this function (e.g. from server.ts). - * @param {ExpressDataHandlerOptions} options - Handler options: loaders and optional endpoint (defaults to {@link LOADER_DATA_ENDPOINT}) + * @param {AngularSitecoreConfig} config - Resolved Sitecore configuration (drives default site/locale). + * @param {LoaderDataServiceOptions} options - Handler options: loaders, cache, and optional endpoint (defaults to {@link LOADER_DATA_ENDPOINT}) * @returns Express middleware that handles the data endpoint * @example * ```typescript @@ -107,10 +101,11 @@ function parseLoaderRequest( * @public */ export function createLoaderDataServiceMiddleware( - options: ExpressDataHandlerOptions + config: AngularSitecoreConfig, + options: LoaderDataServiceOptions ): ExpressMiddleware { const { loaders, cache, endpoint = LOADER_DATA_ENDPOINT } = options; - const serverLoaderData = new ServerLoaderRunner(loaders, cache); + const serverLoaderRunner = new ServerLoaderRunner(loaders, config, cache); return async ( req: ExpressRequest, @@ -124,12 +119,7 @@ export function createLoaderDataServiceMiddleware( try { const parsed = parseLoaderRequest(req); if ('loaderId' in parsed) { - // 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)); + const result = toApiResponse(await serverLoaderRunner.resolve(parsed)); sendResponse(res, result); } else { res diff --git a/packages/angular/src/server/middleware/models.ts b/packages/angular/src/server/middleware/models.ts new file mode 100644 index 0000000000..bc7eca7790 --- /dev/null +++ b/packages/angular/src/server/middleware/models.ts @@ -0,0 +1,70 @@ +import { InjectionToken } from '@angular/core'; +import type { CsdkRequestParams, CsdkRequestData } from '../../loaders/models'; +import type { PathPattern } from '../utils'; +import type { ExpressRequest, ExpressResponse, CookieOptions } from '../../config/http-types'; +export { ExpressRequest, ExpressResponse, CookieOptions }; +/** + * Injection token for the request context extractor (used by tests to provide a mock via TestBed). + * @internal + */ +export const EXTRACT_REQUEST_CONTEXT_TOKEN = new InjectionToken< + (req: ExpressRequest) => CsdkRequestData +>('EXTRACT_REQUEST_CONTEXT'); + +/** + * Express next function type + * @public + */ +export type ExpressNextFunction = (error?: unknown) => void; +export interface CsdkExpressRequest extends ExpressRequest { + scParams?: CsdkRequestParams; +} +/** + * Express-compatible middleware type + * @public + */ +export type ExpressMiddleware = ( + req: ExpressRequest, + res: ExpressResponse, + next: ExpressNextFunction +) => void | Promise; + +/** + * Matcher configuration for middleware path inclusion/exclusion. Each pattern is either a `string` + * (matched exactly) or a `RegExp` (matched with `.test`). + * @public + */ +export interface MiddlewareMatcher { + /** + * Paths to **include**. If provided, only matching paths are processed. + * Example: `['/about', /^\/products\//]` + */ + includePaths?: PathPattern[]; + /** + * Paths to **exclude** (always skipped), evaluated before {@link MiddlewareMatcher.includePaths}. + * Example: `['/health', /\.json$/]` + */ + excludePaths?: PathPattern[]; +} + +/** + * Base configuration for server middlewares (multisite, personalization, redirects, etc). + * Provides common path matching and skip logic. + * @public + */ +export interface BaseMiddlewareOptions { + /** + * Enable/disable this middleware. When false, all requests skip it. + * @default true + */ + enabled?: boolean; + /** + * Custom request predicate to skip middleware execution. Runs after built-in checks. + */ + skip?: (req: ExpressRequest) => boolean; + /** + * Path matching rules (glob patterns) to control which requests this middleware processes. + * Integrates with default exclusions (API routes, static files, editing/preview). + */ + matcher?: MiddlewareMatcher; +} diff --git a/packages/angular/src/server/middleware/multisite-middleware.spec.ts b/packages/angular/src/server/middleware/multisite-middleware.spec.ts new file mode 100644 index 0000000000..46825b223a --- /dev/null +++ b/packages/angular/src/server/middleware/multisite-middleware.spec.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SITE_KEY, type SiteInfo } from '@sitecore-content-sdk/content/site'; +import { PREVIEW_KEY } from '@sitecore-content-sdk/content/editing'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import { + createMultisiteMiddleware, + getHostname, + type MultisiteMiddlewareOptions, +} from './multisite-middleware'; +import type { CsdkExpressRequest, ExpressResponse } from './models'; + +const SITES: SiteInfo[] = [ + { hostName: 'a.example.com', language: '', name: 'site-a' }, + { hostName: 'b.example.com', language: '', name: 'site-b' }, +]; + +function createOptions( + overrides: Partial = {} +): MultisiteMiddlewareOptions { + return { + enabled: true, + sites: SITES, + defaultSite: 'site-a', + ...overrides, + } as MultisiteMiddlewareOptions; +} + +function createReq(overrides: Partial = {}): CsdkExpressRequest { + return { + method: 'GET', + path: '/about', + url: '/about', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'a.example.com' }, + ...overrides, + }; +} + +function createRes() { + return { cookie: vi.fn(), setHeader: vi.fn() } as unknown as ExpressResponse & { + cookie: ReturnType; + }; +} + +describe('createMultisiteMiddleware', () => { + const next = vi.fn(); + + beforeEach(() => vi.clearAllMocks()); + + it('returns a middleware', () => { + expect(createMultisiteMiddleware(createOptions({ enabled: false }))).toBeTypeOf('function'); + }); + + it('resolves the site from the sc_site query parameter', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams?.siteName).toBe('site-b'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('resolves the site from the cookie when useCookieResolution allows it', () => { + const req = createReq({ cookies: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions({ useCookieResolution: () => true }))( + req, + createRes(), + next + ); + expect(req.scParams?.siteName).toBe('site-b'); + }); + + it('resolves the site from the hostname when no query or cookie is present', () => { + const req = createReq({ headers: { host: 'b.example.com' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams?.siteName).toBe('site-b'); + }); + + it('writes the resolved site to the site cookie', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + const res = createRes(); + createMultisiteMiddleware(createOptions())(req, res, next); + expect(res.cookie).toHaveBeenCalledWith(SITE_KEY, 'site-b', expect.any(Object)); + }); + + it('skips when disabled', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions({ enabled: false }))(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('is enabled by default when `enabled` is omitted', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + const { enabled, ...optionsWithoutEnabled } = createOptions(); + void enabled; + createMultisiteMiddleware(optionsWithoutEnabled as MultisiteMiddlewareOptions)( + req, + createRes(), + next + ); + expect(req.scParams?.siteName).toBe('site-b'); + }); + + it('skips editing/preview requests', () => { + const req = createReq({ + query: { [SITE_KEY]: 'site-b' }, + cookies: { [PREVIEW_KEY]: 'true' }, + }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + }); + + it('skips api, sitecore and static-file routes', () => { + for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { + const req = createReq({ path, url: path, query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + } + }); + + it('skips when the custom skip predicate returns true', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions({ skip: () => true }))(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + }); + + it('resolves the site from the site query parameter alias', () => { + const req = createReq({ query: { site: 'site-b' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams?.siteName).toBe('site-b'); + }); + + it('falls back to defaultSite when hostname resolves to an empty site name', () => { + const req = createReq({ headers: { host: 'anything.example.com' } }); + createMultisiteMiddleware( + createOptions({ sites: [{ hostName: '*', language: '', name: '' }] }) + )(req, createRes(), next); + expect(req.scParams?.siteName).toBe('site-a'); + }); + + it('still calls next when hostname does not match any configured site', () => { + const req = createReq({ headers: { host: 'unknown.example.com' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('ignores the site cookie when useCookieResolution returns false', () => { + const req = createReq({ cookies: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions({ useCookieResolution: () => false }))( + req, + createRes(), + next + ); + expect(req.scParams?.siteName).toBe('site-a'); + }); + + it('writes resolved scParams to SC_PARAMS_HEADER for SSR handoff', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.headers?.[SC_PARAMS_HEADER]).toBe(JSON.stringify({ siteName: 'site-b' })); + }); + + it('resolves site from /_data loader payload query instead of req.path', () => { + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body: { + loaderId: 'home', + url: '/about', + routeParams: {}, + query: { [SITE_KEY]: 'site-b' }, + }, + }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams?.siteName).toBe('site-b'); + }); + + it('skips paths excluded by a custom matcher', () => { + const req = createReq({ path: '/health', url: '/health', query: { [SITE_KEY]: 'site-b' } }); + createMultisiteMiddleware(createOptions({ matcher: { excludePaths: ['/health'] } }))( + req, + createRes(), + next + ); + expect(req.scParams).toBeUndefined(); + }); + + it('still resolves the site when response does not support cookies', () => { + const req = createReq({ query: { [SITE_KEY]: 'site-b' } }); + const res = { setHeader: vi.fn() } as unknown as ExpressResponse; + createMultisiteMiddleware(createOptions())(req, res, next); + expect(req.scParams?.siteName).toBe('site-b'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('logs and calls next when site resolution throws', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const req = createReq({ headers: { host: 'unknown.example.com' } }); + createMultisiteMiddleware(createOptions())(req, createRes(), next); + expect(req.scParams).toBeUndefined(); + expect(log).toHaveBeenCalledWith('Multisite middleware failed:'); + log.mockRestore(); + }); +}); + +describe('getHostname', () => { + it('prefers x-forwarded-host over host', () => { + const req = createReq({ + headers: { 'x-forwarded-host': 'proxy.example.com', host: 'origin.example.com' }, + }); + expect(getHostname(req)).toBe('proxy.example.com'); + }); + + it('strips the port from host headers', () => { + const req = createReq({ headers: { host: 'example.com:8443' } }); + expect(getHostname(req)).toBe('example.com'); + }); +}); diff --git a/packages/angular/src/server/middleware/multisite-middleware.ts b/packages/angular/src/server/middleware/multisite-middleware.ts new file mode 100644 index 0000000000..f1a8a5f5c0 --- /dev/null +++ b/packages/angular/src/server/middleware/multisite-middleware.ts @@ -0,0 +1,134 @@ +import { + getHostnameFromHostHeader, + SITE_KEY, + SiteInfo, + SiteResolver, +} from '@sitecore-content-sdk/content/site'; +import { + ExpressMiddleware, + ExpressNextFunction, + ExpressResponse, + CookieOptions, + BaseMiddlewareOptions, + ExpressRequest, + CsdkExpressRequest, +} from './models'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import debug from '../../debug'; +import { getMiddlewareRequest, shouldProcessPath } from './utils'; +import { isEditingPreview } from '../utils'; +import { AngularSitecoreConfig } from '../../config'; + +/** + * Configuration options for the multisite middleware. + * @public + */ +export type MultisiteMiddlewareOptions = BaseMiddlewareOptions & + AngularSitecoreConfig['multisite'] & { + /** Sites to resolve the site from */ + sites?: SiteInfo[]; + /** Default site to use if no site is resolved */ + defaultSite?: string; + }; + +const hostnameMatcher = /(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}/; + +/** + * Get the hostname from the request. + * @param {ExpressRequest} req - The request. + * @returns {string} The hostname. + */ +export function getHostname(req: ExpressRequest): string { + return getHostnameFromHostHeader( + (req.headers?.['x-forwarded-host'] as string | undefined) || + (req.headers?.host as string | undefined) || + hostnameMatcher.exec(req.url ?? '')?.[0] || + '*' + ); +} + +/** + * Create the multisite middleware. + * @param {MultisiteMiddlewareOptions} options - The options. + * @returns {ExpressMiddleware} The multisite middleware. + */ +export function createMultisiteMiddleware(options: MultisiteMiddlewareOptions): ExpressMiddleware { + const siteResolver = new SiteResolver(options.sites ?? []); + return (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + try { + // For browser loader navigations (/_data) routing data comes from the loader payload, not + // the request; getMiddlewareRequest normalizes both into path/query/data. + const { path, query, data } = getMiddlewareRequest(req as CsdkExpressRequest); + + if (options.enabled === false) { + debug.multisite('multisite middleware disabled'); + return next(); + } + + if (isEditingPreview(data.cookies)) { + debug.multisite('skipped (editing/preview mode)'); + return next(); + } + + if (!shouldProcessPath(path, options.matcher)) { + debug.multisite( + 'multisite middleware skipped (path does not match provided include/exclude patterns)' + ); + return next(); + } + + if (options.skip?.(req)) { + debug.multisite('multisite middleware skipped (skip predicate)'); + return next(); + } + const startTimestamp = Date.now(); + debug.multisite('multisite middleware start: %o', { + path, + headers: data.headers, + cookies: data.cookies, + query, + }); + let resolvedSite: string; + const hostname = data.hostname || getHostname(req); + + resolvedSite = + (query[SITE_KEY] as string | undefined) || + (query.site as string | undefined) || + (options.useCookieResolution && + options.useCookieResolution(req) && + (data.cookies?.[SITE_KEY] as string | undefined)) || + siteResolver.getByHost(hostname).name; + + if (!resolvedSite) { + resolvedSite = options.defaultSite ?? ''; + } + (req as CsdkExpressRequest).scParams = { + ...((req as CsdkExpressRequest).scParams || {}), + siteName: resolvedSite, + }; + // Also ride the params on a header so they survive Angular's conversion of the + // Express request to a web Request on the SSR path (same mechanism as editing params). + req.headers = req.headers ?? {}; + req.headers[SC_PARAMS_HEADER] = JSON.stringify((req as CsdkExpressRequest).scParams); + debug.multisite('multisite middleware end in %dms: %o', Date.now() - startTimestamp, { + resolvedSite, + }); + if (res.cookie) { + const defaultCookieAttributes = { + secure: true, + httpOnly: true, + sameSite: 'none', + } as CookieOptions; + res.cookie(SITE_KEY, resolvedSite, defaultCookieAttributes); + } else { + debug.multisite( + 'could not set site cookie, response does not support cookies. Enable cookieParser in your server configuration to changes this.' + ); + } + } catch (error) { + console.log('Multisite middleware failed:'); + console.log(error); + } + next(); + }; +} diff --git a/packages/angular/src/server/middleware/personalize-middleware.spec.ts b/packages/angular/src/server/middleware/personalize-middleware.spec.ts new file mode 100644 index 0000000000..4364caf8ab --- /dev/null +++ b/packages/angular/src/server/middleware/personalize-middleware.spec.ts @@ -0,0 +1,433 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; +import { + CdpHelper, + DEFAULT_VARIANT, + PersonalizeService, +} from '@sitecore-content-sdk/content/personalize'; +import { SITE_KEY } from '@sitecore-content-sdk/content/site'; +import { PREVIEW_KEY } from '@sitecore-content-sdk/content/editing'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import type { CsdkExpressRequest, ExpressMiddleware, ExpressResponse } from './models'; +import type { PersonalizeMiddlewareOptions } from './personalize-middleware'; +const { + initContentSdkMock, + personalizeMock, + personalizeServerPluginMock, + personalizeServerAdapterMock, + analyticsPluginMock, + analyticsServerAdapterMock, +} = vi.hoisted(() => ({ + initContentSdkMock: vi.fn().mockResolvedValue(undefined), + personalizeMock: vi.fn(), + personalizeServerPluginMock: vi.fn(), + personalizeServerAdapterMock: vi.fn(), + analyticsPluginMock: vi.fn(), + analyticsServerAdapterMock: vi.fn(), +})); + +vi.mock('@sitecore-content-sdk/core', async (importOriginal) => ({ + ...(await importOriginal>()), + initContentSdk: initContentSdkMock, +})); +vi.mock('@sitecore-content-sdk/personalize', () => ({ + personalize: personalizeMock, + personalizeServerPlugin: personalizeServerPluginMock, + personalizeServerAdapter: personalizeServerAdapterMock, +})); +vi.mock('@sitecore-content-sdk/analytics-core', () => ({ + analyticsPlugin: analyticsPluginMock, + analyticsServerAdapter: analyticsServerAdapterMock, +})); + +type CreatePersonalizeMiddleware = (options: PersonalizeMiddlewareOptions) => ExpressMiddleware; + +let createPersonalizeMiddleware: CreatePersonalizeMiddleware; + +const getPersonalizeInfo = vi.fn(); +const personalizeService = { + getPersonalizeInfo, +} as unknown as PersonalizeService; + +function createOptions( + overrides: Partial = {} +): PersonalizeMiddlewareOptions { + return { + enabled: true, + contextId: 'context-id', + defaultSite: 'website', + locales: ['en', 'da'], + personalizeService, + ...overrides, + }; +} + +function createReq(overrides: Partial = {}): CsdkExpressRequest { + return { + method: 'GET', + path: '/about', + url: '/about', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'example.com' }, + scParams: { siteName: 'website', variantId: DEFAULT_VARIANT }, + ...overrides, + }; +} + +function createRes() { + return { setHeader: vi.fn() } as unknown as ExpressResponse & { + setHeader: ReturnType; + }; +} + +describe('createPersonalizeMiddleware', () => { + const next = vi.fn(); + + beforeAll(async () => { + ({ createPersonalizeMiddleware } = await import('./personalize-middleware')); + }); + + beforeEach(() => { + vi.clearAllMocks(); + initContentSdkMock.mockResolvedValue(undefined); + }); + + it('should populate req.scParams with identified page-level variant', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); + expect(initContentSdkMock).toHaveBeenCalled(); + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'WEB', + currency: 'USD', + friendlyId: CdpHelper.getPageFriendlyId('page-1', 'en'), + language: 'en', + pageVariantIds: ['variant-a'], + }), + { timeout: undefined } + ); + expect(req.scParams).toEqual({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should populate req.scParams.componentVariantIds with identified component-level variants', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['comp1_var1'] }); + personalizeMock.mockResolvedValue({ variantId: 'comp1_var1' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + friendlyId: CdpHelper.getComponentFriendlyId('page-1', 'comp1', 'en'), + pageVariantIds: [`comp1${DEFAULT_VARIANT}`, 'comp1_var1'], + }), + { timeout: undefined } + ); + expect(req.scParams).toEqual({ + siteName: 'website', + variantId: DEFAULT_VARIANT, + componentVariantIds: ['comp1_var1'], + }); + }); + + it('should extract language and content path from locale-prefixed url', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq({ path: '/da/products/shoes', url: '/da/products/shoes' }); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/products/shoes', 'da', 'website'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should use defaultLanguage when path has no locale prefix', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions({ defaultLanguage: 'fr' }))( + req, + createRes(), + next + ); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'fr', 'website'); + }); + + it('should resolve site from cookie and fall back to defaultSite when req.scParams is not set', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const middleware = createPersonalizeMiddleware(createOptions()); + + await middleware( + createReq({ scParams: undefined, cookies: { [SITE_KEY]: 'other-site' } }), + createRes(), + next + ); + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'other-site'); + + await middleware(createReq({ scParams: undefined }), createRes(), next); + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); + }); + + it('should skip when disabled', async () => { + const req = createReq(); + + await createPersonalizeMiddleware(createOptions({ enabled: false }))(req, createRes(), next); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip and warn when edge configuration is missing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const middleware = createPersonalizeMiddleware( + createOptions({ contextId: undefined, personalizeService: undefined }) + ); + + await middleware(createReq(), createRes(), next); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('requires Edge configuration')); + expect(next).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('should skip when custom skip callback returns true', async () => { + await createPersonalizeMiddleware(createOptions({ skip: () => true }))( + createReq(), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip api, sitecore and file routes', async () => { + const middleware = createPersonalizeMiddleware(createOptions()); + + for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { + await middleware(createReq({ path, url: path }), createRes(), next); + } + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(3); + }); + + it('should skip in preview mode', async () => { + await createPersonalizeMiddleware(createOptions())( + createReq({ cookies: { [PREVIEW_KEY]: 'true' } }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip when personalize info is not found or has no variants', async () => { + const middleware = createPersonalizeMiddleware(createOptions()); + const req = createReq(); + + getPersonalizeInfo.mockResolvedValueOnce(undefined); + await middleware(req, createRes(), next); + + getPersonalizeInfo.mockResolvedValueOnce({ pageId: 'page-1', variantIds: [] }); + await middleware(req, createRes(), next); + + expect(personalizeMock).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(2); + }); + + it('should skip prefetch requests and disable caching', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + const req = createReq({ headers: { host: 'example.com', purpose: 'prefetch' } }); + const res = createRes(); + + await createPersonalizeMiddleware(createOptions())(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(personalizeMock).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + }); + + it('should ignore variants not configured for the route', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'unknown-variant' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should pass utm params, referrer and geo data to personalize', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq({ + query: { utm_campaign: 'sale', utm_source: 'newsletter' }, + headers: { host: 'example.com', referer: 'https://referrer.example' }, + }); + + await createPersonalizeMiddleware( + createOptions({ extractGeoDataCb: () => ({ city: 'Oslo' }) }) + )(req, createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + referrer: 'https://referrer.example', + utm: { + campaign: 'sale', + content: undefined, + medium: undefined, + source: 'newsletter', + }, + }, + geo: { city: 'Oslo' }, + }), + { timeout: undefined } + ); + }); + + it('should call next and not fail the request when personalization throws', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + getPersonalizeInfo.mockRejectedValue(new Error('edge unavailable')); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); + + it('is enabled by default when enabled is omitted', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const { enabled, ...optionsWithoutEnabled } = createOptions(); + void enabled; + + await createPersonalizeMiddleware(optionsWithoutEnabled)(createReq(), createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips paths excluded by a custom matcher', async () => { + await createPersonalizeMiddleware(createOptions({ matcher: { excludePaths: ['/health'] } }))( + createReq({ path: '/health', url: '/health' }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('resolves path and site from /_data loader payload', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + scParams: undefined, + body: { + loaderId: 'home', + url: '/da/products', + routeParams: {}, + query: {}, + }, + cookies: { [SITE_KEY]: 'loader-site' }, + }); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/products', 'da', 'loader-site'); + }); + + it('writes SC_PARAMS_HEADER when variants are identified', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.headers?.[SC_PARAMS_HEADER]).toBe( + JSON.stringify({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }) + ); + }); + + it('skips when site name cannot be resolved', async () => { + await createPersonalizeMiddleware(createOptions({ defaultSite: undefined }))( + createReq({ scParams: undefined, cookies: {} }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('merges getExtraUtmParams into experience params', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + + await createPersonalizeMiddleware( + createOptions({ getExtraUtmParams: () => ({ medium: 'email', campaign: 'override' }) }) + )(createReq({ query: { utm_campaign: 'original' } }), createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + utm: expect.objectContaining({ medium: 'email', campaign: 'override' }), + }), + }), + { timeout: undefined } + ); + }); + + it('treats sec-purpose prefetch headers like purpose prefetch', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + const res = createRes(); + + await createPersonalizeMiddleware(createOptions())( + createReq({ headers: { host: 'example.com', 'sec-purpose': 'prefetch' } }), + res, + next + ); + + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(personalizeMock).not.toHaveBeenCalled(); + }); + + it('calls next when personalize execution throws after info is loaded', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockRejectedValue(new Error('cdp unavailable')); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); diff --git a/packages/angular/src/server/middleware/personalize-middleware.ts b/packages/angular/src/server/middleware/personalize-middleware.ts new file mode 100644 index 0000000000..7357a0cd7b --- /dev/null +++ b/packages/angular/src/server/middleware/personalize-middleware.ts @@ -0,0 +1,343 @@ +import { + CdpHelper, + DEFAULT_VARIANT, + getGroomedVariantIds, + PersonalizeInfo, + PersonalizeService, +} from '@sitecore-content-sdk/content/personalize'; +import { SITE_KEY } from '@sitecore-content-sdk/content/site'; +import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; +import { createGraphQLClientFactory } from '@sitecore-content-sdk/content/client'; +import { initContentSdk } from '@sitecore-content-sdk/core'; +import { analyticsPlugin, analyticsServerAdapter } from '@sitecore-content-sdk/analytics-core'; +import { + personalize, + personalizeServerPlugin, + personalizeServerAdapter, +} from '@sitecore-content-sdk/personalize'; +import { + CsdkExpressRequest, + ExpressMiddleware, + ExpressNextFunction, + ExpressResponse, + BaseMiddlewareOptions, + ExpressRequest, +} from './models'; +import { splitLocaleFromPath } from '../../i18n/locale-utils'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import { getMiddlewareRequest, shouldProcessPath, toNodeAdapterPair } from './utils'; +import debug from '../../debug'; +import { isEditingPreview } from '../utils'; + +/** + * Object model of Experience Context data + * @public + */ +export type ExperienceParams = { + referrer: string; + utm: { + [key: string]: string | undefined; + campaign: string | undefined; + source: string | undefined; + medium: string | undefined; + content: string | undefined; + }; +}; + +/** + * Represents the geolocation data used for personalization + * @public + */ +export type PersonalizeGeoData = { + city?: string; + country?: string; + region?: string; +}; + +/** + * Configuration for the personalize middleware + * @public + */ +export type PersonalizeMiddlewareOptions = BaseMiddlewareOptions & + Partial & + Partial & { + /** Locales used to extract the language from the request path */ + locales?: string[]; + /** Fallback language when the request path has no locale prefix. Default is `'en'` */ + defaultLanguage?: string; + /** Fallback site name when not resolved by the multisite middleware or site cookie */ + defaultSite?: string; + /** Override the personalize service instance */ + personalizeService?: PersonalizeService; + /** Get extra UTM parameters from the request */ + getExtraUtmParams?: (req: ExpressRequest) => Partial; + /** Extract geolocation data from the request */ + extractGeoDataCb?: (req: ExpressRequest) => Promise | PersonalizeGeoData; + }; + +type PersonalizeExecution = { + friendlyId: string; + variantIds: string[]; +}; + +const isPrefetch = (req: ExpressRequest): boolean => + [req.headers?.purpose, req.headers?.['sec-purpose']].some( + (header) => typeof header === 'string' && header.includes('prefetch') + ); + +const getExperienceParams = ( + query: Record, + referrer: string, + extraUtmParams: Partial = {} +): ExperienceParams => { + const utmParam = (name: string) => { + const value = query[name]; + return (Array.isArray(value) ? value[0] : value) || undefined; + }; + return { + referrer, + utm: { + campaign: utmParam('utm_campaign'), + content: utmParam('utm_content'), + medium: utmParam('utm_medium'), + source: utmParam('utm_source'), + ...extraUtmParams, + }, + }; +}; + +/** + * Aggregates personalize executions (friendly id + variant ids) for the route, + * grouping page-level ("") and component-level ("_") variants. + * @param {PersonalizeInfo} personalizeInfo the route personalize information + * @param {string} language the language + * @param {string} [scope] optional Sitecore Personalize scope + * @returns {PersonalizeExecution[]} An array of personalize executions + */ +const getPersonalizeExecutions = ( + personalizeInfo: PersonalizeInfo, + language: string, + scope?: string +): PersonalizeExecution[] => + personalizeInfo.variantIds.reduce((results, variantId) => { + const isComponentVariant = variantId.includes('_'); + const componentId = variantId.split('_')[0]; + const friendlyId = isComponentVariant + ? CdpHelper.getComponentFriendlyId(personalizeInfo.pageId, componentId, language, scope) + : CdpHelper.getPageFriendlyId(personalizeInfo.pageId, language, scope); + const execution = results.find((x) => x.friendlyId === friendlyId); + if (execution) { + execution.variantIds.push(variantId); + } else { + results.push({ + friendlyId, + // The default/control variant ("_default") is also a valid execution result + variantIds: isComponentVariant + ? [`${componentId}${DEFAULT_VARIANT}`, variantId] + : [variantId], + }); + } + return results; + }, []); + +/** + * Middleware to support Sitecore Personalize. + * Identifies page/component variants for the request via Sitecore CDP and populates + * `req.scParams.variantId` and `req.scParams.componentVariantIds` for downstream layout personalization. + * @param {PersonalizeMiddlewareOptions} options personalize middleware options + * @returns {ExpressMiddleware} Express middleware + * @public + */ +export function createPersonalizeMiddleware( + options: PersonalizeMiddlewareOptions +): ExpressMiddleware { + const personalizeService = + options.personalizeService ?? + (options.contextId || options.clientContextId + ? new PersonalizeService({ + clientFactory: createGraphQLClientFactory({ + api: { + edge: { + contextId: options.contextId as string, + clientContextId: options.clientContextId, + edgeUrl: options.edgeUrl, + }, + }, + }), + timeout: options.edgeTimeout, + scope: options.scope, + fetch: fetch, + }) + : null); + + if (!personalizeService) { + console.warn( + '[PersonalizeMiddleware] Personalize middleware requires Edge configuration (contextId/clientContextId). ' + + 'Personalize features will be disabled. This is expected in local container development.' + ); + } + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + try { + // `enabled` defaults to true: omitting it keeps the middleware on (see BaseMiddlewareOptions). + if (options.enabled === false || !personalizeService) { + debug.personalize('personalize middleware disabled or not configured'); + return next(); + } + // For browser loader navigations (/_data) routing data comes from the loader payload, not + // the request; getMiddlewareRequest normalizes both into path/query/data. + const { path, query, data } = getMiddlewareRequest(req); + + if (isEditingPreview(data.cookies)) { + debug.personalize('skipped (editing/preview mode)'); + return next(); + } + + if (!shouldProcessPath(path, options.matcher)) { + debug.personalize('personalize middleware skipped (path does not match)'); + return next(); + } + + if (options.skip?.(req)) { + debug.personalize('personalize middleware skipped (skip predicate)'); + return next(); + } + + const startTimestamp = Date.now(); + const { locale, nonLocalePath } = splitLocaleFromPath(path, options.locales ?? []); + const language = locale || options.defaultLanguage || 'en'; + const siteName = + (req as CsdkExpressRequest).scParams?.siteName || + data.cookies?.[SITE_KEY] || + options.defaultSite; + const hostHeader = data.headers?.['x-forwarded-host'] ?? data.headers?.host; + const hostname = + (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader)?.split(':')[0] || 'localhost'; + + debug.personalize('personalize middleware start: %o', { + path: nonLocalePath, + language, + siteName, + hostname, + headers: req.headers, + }); + + if (!siteName) { + debug.personalize('skipped (site could not be resolved)'); + return next(); + } + + // Get personalization info from Experience Edge + const personalizeInfo = await personalizeService.getPersonalizeInfo( + nonLocalePath, + language, + siteName + ); + if (!personalizeInfo) { + // Likely an invalid route / language + debug.personalize('skipped (personalize info not found)'); + return next(); + } + if (personalizeInfo.variantIds.length === 0) { + debug.personalize('skipped (no personalization configured)'); + return next(); + } + + if (isPrefetch(req)) { + // Personalized, but this is a prefetch request. + // Don't execute a personalize request; otherwise, the metrics for component A/B experiments would be inaccurate. + // Disable caching to force revalidation on navigation (personalization WILL be influenced). + debug.personalize('skipped (prefetch)'); + res.setHeader?.('x-proxy-cache', 'no-cache'); + res.setHeader?.('Cache-Control', 'no-store, must-revalidate'); + return next(); + } + + // Express req/res are http.IncomingMessage/ServerResponse at runtime; the minimal + // Express interfaces don't declare that, so cast for the cookie-based server adapters. + const { req: httpReq, res: httpRes } = toNodeAdapterPair(req as CsdkExpressRequest, res); + await initContentSdk({ + config: { + contextId: options.contextId as string, + edgeUrl: options.edgeUrl, + siteName, + }, + plugins: [ + analyticsPlugin({ + options: { + enableCookie: true, + cookieDomain: hostname, + }, + // personalize middleware will only run on server for Angular and we explicitly use server adapters + adapter: analyticsServerAdapter(httpReq, httpRes), + }), + personalizeServerPlugin({ + options: { + enablePersonalizeCookie: true, + }, + adapter: personalizeServerAdapter(httpReq, httpRes), + }), + ], + }); + + const geo = options.extractGeoDataCb ? await options.extractGeoDataCb(req) : undefined; + const params = getExperienceParams( + query, + (data.referrer as string) || (data.headers?.referer as string) || '', + options.getExtraUtmParams?.(req) + ); + const executions = getPersonalizeExecutions(personalizeInfo, language, options.scope); + const identifiedVariantIds: string[] = []; + + await Promise.all( + executions.map(async (execution) => { + debug.personalize('executing experience for %s %o', execution.friendlyId, params); + const personalization = (await personalize( + { + channel: options.channel || 'WEB', + currency: options.currency ?? 'USD', + friendlyId: execution.friendlyId, + params, + language, + pageVariantIds: execution.variantIds, + ...(geo && { geo }), + }, + { timeout: options.cdpTimeout } + )) as { variantId?: string } | null; + const variantId = personalization?.variantId; + if (!variantId) return; + if (!execution.variantIds.includes(variantId)) { + debug.personalize('invalid variant %s', variantId); + } else { + identifiedVariantIds.push(variantId); + } + }) + ); + + if (identifiedVariantIds.length === 0) { + debug.personalize('skipped (no variant(s) identified)'); + return next(); + } + + const { variantId, componentVariantIds } = getGroomedVariantIds(identifiedVariantIds); + (req as CsdkExpressRequest).scParams = { + ...((req as CsdkExpressRequest).scParams || {}), + variantId, + componentVariantIds, + }; + // Also ride the params on a header so they survive Angular's conversion of the + // Express request to a web Request on the SSR path (same mechanism as editing params). + req.headers = req.headers ?? {}; + req.headers[SC_PARAMS_HEADER] = JSON.stringify((req as CsdkExpressRequest).scParams); + + debug.personalize('personalize middleware end in %dms: %o', Date.now() - startTimestamp, { + variantId, + componentVariantIds, + }); + } catch (error) { + console.log('Personalize middleware failed:'); + console.log(error); + } + next(); + }; +} diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts index e32d4d8041..cee589848a 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.spec.ts @@ -4,7 +4,8 @@ import { createSitecoreRevalidateMiddleware } from './sitecore-revalidate-middle import { createLoaderCache } from '../cache/loader-cache'; import { buildCacheKey } from '../cache/cache-key'; import { buildLoaderCacheTags } from '../cache/cache-tags'; -import type { ExpressRequest, ExpressResponse } from '../models'; +import type { ExpressRequest, ExpressResponse } from './models'; +import { makeLoaderContext, mockScParams } from '../../testing/loader-spec-helpers'; function createMockRes() { return { @@ -25,11 +26,14 @@ describe('createSitecoreRevalidateMiddleware', () => { delete process.env.SITECORE_REVALIDATE_SECRET; next.mockClear(); cache = createLoaderCache({ revalidate: 300 }); - const built = buildCacheKey('page', { - url: '/about', - params: { site: 'demo', locale: 'en' }, - query: {}, - }); + const built = buildCacheKey( + 'page', + makeLoaderContext({ + url: '/about', + routeParams: { locale: 'en' }, + scParams: mockScParams({ siteName: 'demo' }), + }) + ); cacheKey = built.key; await cache.set( cacheKey, @@ -172,13 +176,21 @@ describe('createSitecoreRevalidateMiddleware', () => { }); 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 dictBuilt = buildCacheKey( + 'dictionary', + makeLoaderContext({ + url: '/', + routeParams: { locale: 'en' }, + scParams: mockScParams({ siteName: 'demo' }), + }) + ); const dictKey = dictBuilt.key; - await cache.set(dictKey, { hello: 'world' }, 300, buildLoaderCacheTags('dictionary', dictBuilt.dimensions, dictKey)); + await cache.set( + dictKey, + { hello: 'world' }, + 300, + buildLoaderCacheTags('dictionary', dictBuilt.dimensions, dictKey) + ); const middleware = createSitecoreRevalidateMiddleware({ cache, @@ -218,7 +230,10 @@ describe('createSitecoreRevalidateMiddleware', () => { ...cache, invalidate: vi.fn().mockRejectedValue(new Error('invalidate failed')), }; - const middleware = createSitecoreRevalidateMiddleware({ cache: failingCache, defaultLocale: 'en' }); + const middleware = createSitecoreRevalidateMiddleware({ + cache: failingCache, + defaultLocale: 'en', + }); const res = createMockRes(); await middleware( diff --git a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts index 4ee46cb575..56f0c59a5d 100644 --- a/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts +++ b/packages/angular/src/server/middleware/sitecore-revalidate-middleware.ts @@ -1,5 +1,5 @@ import type { SiteInfo } from '@sitecore-content-sdk/content/site'; -import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from '../models'; +import { ExpressMiddleware, ExpressNextFunction, ExpressRequest, ExpressResponse } from './models'; import { LoaderCache } from '../../loaders/models'; import { buildLoaderDictionaryCacheTagsFromSites } from '../cache/cache-tags'; import { dedupeCacheStrings } from '../cache/utils'; @@ -49,7 +49,7 @@ export interface SitecoreRevalidateMiddlewareOptions { } /** - * Express middleware aligned with Next.js `createSitecoreRevalidateRouteHandler`. + * Express middleware aligned with other frameworks' `createSitecoreRevalidateRouteHandler`. * * Handles `POST /api/revalidate` (configurable via `endpoint`): * - Authenticates with `SITECORE_REVALIDATE_SECRET` / `x-revalidate-secret` when configured. diff --git a/packages/angular/src/server/middleware/utils.spec.ts b/packages/angular/src/server/middleware/utils.spec.ts new file mode 100644 index 0000000000..7f6ae1b4a9 --- /dev/null +++ b/packages/angular/src/server/middleware/utils.spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import { SITE_KEY } from '@sitecore-content-sdk/content/site'; +import type { ExpressRequest } from './models'; +import { + getMiddlewareRequest, + isDataLoaderRequest, + parseLoaderRequest, + shouldProcessPath, +} from './utils'; + +function createReq(overrides: Partial = {}): ExpressRequest { + return { + method: 'GET', + path: '/about', + url: '/about', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'example.com' }, + ...overrides, + }; +} + +describe('isDataLoaderRequest', () => { + it('returns true for the default /_data endpoint', () => { + expect(isDataLoaderRequest(createReq({ path: '/_data' }))).toBe(true); + }); + + it('returns false for regular page routes', () => { + expect(isDataLoaderRequest(createReq({ path: '/about' }))).toBe(false); + }); + + it('supports a custom data endpoint', () => { + expect(isDataLoaderRequest(createReq({ path: '/custom-data' }), '/custom-data')).toBe(true); + }); +}); + +describe('parseLoaderRequest', () => { + it('parses a POST body into LoaderRunnerInit with server-derived csdkRequestData', () => { + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + body: { + loaderId: 'home', + url: '/products?color=red', + routeParams: { id: '1' }, + query: { color: 'red' }, + }, + headers: { host: 'shop.example.com' }, + cookies: { [SITE_KEY]: 'website' }, + }); + + const parsed = parseLoaderRequest(req); + expect(parsed).toMatchObject({ + loaderId: 'home', + url: '/products?color=red', + query: { color: 'red' }, + }); + expect('csdkRequestData' in parsed && parsed.csdkRequestData?.hostname).toBe( + 'shop.example.com' + ); + expect('csdkRequestData' in parsed && parsed.csdkRequestData?.cookies?.[SITE_KEY]).toBe( + 'website' + ); + }); + + it('returns 400 when POST body is missing loaderId', () => { + expect(parseLoaderRequest(createReq({ method: 'POST', path: '/_data', body: {} }))).toEqual({ + status: 400, + message: 'Missing loaderId', + }); + }); + + it('parses GET query params into LoaderRunnerInit', () => { + const parsed = parseLoaderRequest( + createReq({ + method: 'GET', + path: '/_data', + query: { loaderId: 'page', url: '/contact', utm_source: 'email' }, + }) + ); + + expect(parsed).toMatchObject({ + loaderId: 'page', + url: '/contact', + query: { utm_source: 'email' }, + routeParams: {}, + }); + }); + + it('returns 400 when GET query is missing loaderId', () => { + expect(parseLoaderRequest(createReq({ method: 'GET', path: '/_data', query: {} }))).toEqual({ + status: 400, + message: 'Missing loaderId', + }); + }); + + it('returns 405 for unsupported methods', () => { + expect(parseLoaderRequest(createReq({ method: 'PUT', path: '/_data' }))).toEqual({ + status: 405, + message: 'Method not allowed', + }); + }); +}); + +describe('getMiddlewareRequest', () => { + it('returns path and query from a regular page request', () => { + const req = createReq({ + path: '/about', + query: { [SITE_KEY]: 'site-b' }, + headers: { host: 'a.example.com' }, + }); + + const result = getMiddlewareRequest(req); + expect(result.path).toBe('/about'); + expect(result.query).toEqual({ [SITE_KEY]: 'site-b' }); + expect(result.data.hostname).toBe('a.example.com'); + }); + + it('uses loader payload path and query for /_data POST requests', () => { + const req = createReq({ + method: 'POST', + path: '/_data', + body: { + loaderId: 'home', + url: '/products/shoes', + routeParams: {}, + query: { [SITE_KEY]: 'site-b', page: '2' }, + }, + headers: { host: 'a.example.com', referer: 'https://referrer.example' }, + }); + + const result = getMiddlewareRequest(req); + expect(result.path).toBe('/products/shoes'); + expect(result.query).toEqual({ [SITE_KEY]: 'site-b', page: '2' }); + expect(result.referrer).toBe('https://referrer.example'); + expect(result.data.hostname).toBe('a.example.com'); + }); + + it('falls back to req.path when /_data payload cannot be parsed', () => { + const req = createReq({ method: 'POST', path: '/_data', body: {} }); + const result = getMiddlewareRequest(req); + expect(result.path).toBe('/_data'); + }); + + it('reads scParams from the SC_PARAMS_HEADER when present', () => { + const req = createReq({ + headers: { + host: 'example.com', + [SC_PARAMS_HEADER]: JSON.stringify({ siteName: 'from-header', variantId: '_default' }), + }, + }); + + expect(getMiddlewareRequest(req).data.scParams?.siteName).toBe('from-header'); + }); +}); + +describe('shouldProcessPath', () => { + describe('default exclusions', () => { + it('processes a normal layout route', () => { + expect(shouldProcessPath('/about')).toBe(true); + expect(shouldProcessPath('/products/shoes')).toBe(true); + expect(shouldProcessPath('/')).toBe(true); + }); + + it('skips API routes', () => { + expect(shouldProcessPath('/api')).toBe(false); + expect(shouldProcessPath('/api/data')).toBe(false); + }); + + it('skips Sitecore routes', () => { + expect(shouldProcessPath('/sitecore/render')).toBe(false); + }); + + it('skips static files (final segment has an extension)', () => { + // Regression guard: the personalize middleware must not run on asset requests. + expect(shouldProcessPath('/assets/logo.png')).toBe(false); + expect(shouldProcessPath('/styles.css')).toBe(false); + expect(shouldProcessPath('/favicon.ico')).toBe(false); + }); + + it('does not over-match: /api-docs is a real route, not an API path', () => { + expect(shouldProcessPath('/api-docs')).toBe(true); + }); + }); + + describe('custom excludePaths', () => { + it('skips an exact string match', () => { + expect(shouldProcessPath('/health', { excludePaths: ['/health'] })).toBe(false); + expect(shouldProcessPath('/about', { excludePaths: ['/health'] })).toBe(true); + }); + + it('skips a regex match', () => { + expect(shouldProcessPath('/legal/terms', { excludePaths: [/^\/legal\//] })).toBe(false); + }); + }); + + describe('custom includePaths', () => { + it('processes only paths that match an include pattern', () => { + expect(shouldProcessPath('/about', { includePaths: ['/about'] })).toBe(true); + expect(shouldProcessPath('/contact', { includePaths: ['/about'] })).toBe(false); + }); + + it('supports regex include patterns', () => { + const matcher = { includePaths: [/^\/products\//] }; + expect(shouldProcessPath('/products/shoes', matcher)).toBe(true); + expect(shouldProcessPath('/about', matcher)).toBe(false); + }); + }); + + describe('precedence', () => { + it('excludePaths wins over includePaths', () => { + expect( + shouldProcessPath('/about', { includePaths: ['/about'], excludePaths: ['/about'] }) + ).toBe(false); + }); + + it('default exclusions win over includePaths', () => { + // A consumer cannot re-include a default-excluded path; documented limitation. + expect(shouldProcessPath('/api/preview', { includePaths: [/^\/api\//] })).toBe(false); + }); + }); +}); diff --git a/packages/angular/src/server/middleware/utils.ts b/packages/angular/src/server/middleware/utils.ts new file mode 100644 index 0000000000..97e1b6ace6 --- /dev/null +++ b/packages/angular/src/server/middleware/utils.ts @@ -0,0 +1,165 @@ +import { LOADER_DATA_ENDPOINT } from '../constants'; +import type { CsdkRequestData, LoaderPayload, LoaderRunnerInit } from '../../loaders/models'; +import { extractRequestData } from '../../loaders/utils'; +import type { ExpressRequest, ExpressResponse, MiddlewareMatcher } from './models'; +import { analyticsServerAdapter } from '@sitecore-content-sdk/analytics-core'; +import { matches, type PathPattern } from '../utils'; + +/** + * Whether the request is a browser loader navigation to the `/_data` endpoint (rather than a + * regular SSR page request). Such requests carry their routing data in the loader payload, not in + * `req.path`/`req.query`. + * @param {ExpressRequest} req - Incoming request. + * @param {string} [dataEndpoint] - Loader data endpoint (default `/_data`). + * @returns {boolean} True for `/_data` requests. + * @public + */ +export function isDataLoaderRequest( + req: ExpressRequest, + dataEndpoint = LOADER_DATA_ENDPOINT +): boolean { + return req.path === dataEndpoint; +} + +/** + * Parses a `/_data` request (POST body or GET query) into a {@link LoaderRunnerInit}, or a + * validation error. `csdkRequestData` is always server-derived via {@link extractRequestData}, so + * request-data-shaped values in the payload can't spoof site/variant resolution. + * @param {ExpressRequest} req - Incoming `/_data` request. + * @returns {LoaderRunnerInit | { status: number; message: string }} Parsed payload or error. + * @public + */ +export function parseLoaderRequest( + req: ExpressRequest +): LoaderRunnerInit | { status: number; message: string } { + if (req.method === 'POST') { + const body = req.body as LoaderPayload; + if (!body?.loaderId) return { status: 400, message: 'Missing loaderId' }; + return { ...body, csdkRequestData: extractRequestData(req) }; + } + if (req.method === 'GET') { + const loaderId = String(req.query?.loaderId ?? ''); + if (!loaderId) return { status: 400, message: 'Missing loaderId' }; + const query: Record = {}; + for (const [key, value] of Object.entries(req.query ?? {})) { + if (key !== 'loaderId' && key !== 'url' && typeof value === 'string') query[key] = value; + } + return { + loaderId, + url: String(req.query?.url ?? ''), + routeParams: {}, + query, + csdkRequestData: extractRequestData(req) ?? null, + }; + } + return { status: 405, message: 'Method not allowed' }; +} + +/** + * Normalized request inputs the multisite and personalize middlewares resolve against. + * @public + */ +export interface MiddlewareRequest { + /** Target route path (query string stripped). */ + path: string; + /** Target route query parameters. */ + query: Record; + /** Server-derived request data: hostname, headers, cookies, scParams, preview data. */ + data: CsdkRequestData; + /** Referrer URL from the request. */ + referrer?: string; +} + +/** + * Resolves the inputs the multisite/personalize middlewares should use, hiding the difference + * between regular page requests and browser loader navigations. For `/_data` requests the route and + * query come from the loader payload (via {@link parseLoaderRequest}); otherwise from the request + * itself. Headers/cookies/hostname always come from {@link extractRequestData}, so resolution reads + * the same shape either way and can then enrich `req.scParams` with site/variants. + * @param {ExpressRequest} req - Incoming request. + * @param {string} [dataEndpoint] - Loader data endpoint (default `/_data`). + * @returns {MiddlewareRequest} Normalized path/query/data. + * @public + */ +export function getMiddlewareRequest( + req: ExpressRequest, + dataEndpoint = LOADER_DATA_ENDPOINT +): MiddlewareRequest { + const referrer = (req.headers?.referer as string | undefined) ?? req.referrer; + if (isDataLoaderRequest(req, dataEndpoint)) { + const parsed = parseLoaderRequest(req); + if ('loaderId' in parsed) { + return { + path: (parsed.url || req.path).split('?')[0], + query: parsed.query ?? {}, + referrer, + data: parsed.csdkRequestData ?? extractRequestData(req), + }; + } + } + return { + path: req.path, + query: req.query ?? {}, + referrer, + data: extractRequestData(req), + }; +} + +/** + * Server adapter request/response types derived from {@link analyticsServerAdapter}. + * Avoids importing `@types/node` at middleware call sites that use {@link CsdkExpressRequest}. + */ +export type NodeAdapterRequest = Parameters[0]; +export type NodeAdapterResponse = Parameters[1]; + +/** + * Express req/res are Node `IncomingMessage`/`ServerResponse` at runtime; cast for cookie adapters. + * @param {ExpressRequest} req - Content SDK Express request + * @param {ExpressResponse} res - Content SDK Express response + * @returns {NodeAdapterRequest & NodeAdapterResponse} The Node adapter request and response + */ +export function toNodeAdapterPair( + req: ExpressRequest, + res: ExpressResponse +): { req: NodeAdapterRequest; res: NodeAdapterResponse } { + return { + req: req as unknown as NodeAdapterRequest, + res: res as unknown as NodeAdapterResponse, + }; +} + +/** + * Default patterns excluded from all middlewares. These are routes that never carry Sitecore + * layout data, so site/variant resolution would only waste an Edge/CDP call. + * @internal + */ +const DEFAULT_EXCLUDE_PATTERNS: PathPattern[] = [ + /^\/api(\/|$)/, // API routes + /^\/sitecore(\/|$)/, // Sitecore-specific routes + /\.[^/]+$/, // static files (final path segment has an extension, e.g. /assets/logo.png) +]; + +/** + * Determine whether a middleware should process a request based on path matching. + * Applies the default exclusions (API routes, Sitecore routes, static files) and then any custom + * `excludePaths` / `includePaths` from the matcher. Editing/preview gating is handled separately by + * each middleware (see {@link isEditingPreview}), not here. + * + * Precedence: default exclusions and `excludePaths` always win; when `includePaths` is provided the + * path must additionally match at least one include pattern. Each pattern is a `string` (exact + * match) or a `RegExp`. + * @param {string} path - The normalized request path (query string stripped). + * @param {MiddlewareMatcher} [matcher] - Custom include/exclude patterns. + * @returns {boolean} True if the middleware should process this request. + * @public + */ +export function shouldProcessPath(path: string, matcher?: MiddlewareMatcher): boolean { + if ( + DEFAULT_EXCLUDE_PATTERNS.some((pattern) => matches(path, pattern)) || + (matcher?.excludePaths && matcher.excludePaths.some((pattern) => matches(path, pattern))) || + (matcher?.includePaths && !matcher.includePaths.some((pattern) => matches(path, pattern))) + ) { + return false; + } + return true; +} diff --git a/packages/angular/src/server/models.ts b/packages/angular/src/server/models.ts deleted file mode 100644 index 9a0b1d0b24..0000000000 --- a/packages/angular/src/server/models.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import type { RequestContext } 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). - * @internal - */ -export const EXTRACT_REQUEST_CONTEXT_TOKEN = new InjectionToken< - (req: ExpressRequest) => RequestContext ->('EXTRACT_REQUEST_CONTEXT'); - -/** - * Minimal Express Request interface for type safety without requiring Express as a dependency - * @public - */ -export interface ExpressRequest { - method: string; - path: string; - url: string; - body: unknown; - query: Record; - /** - * Cookies from the request (requires cookie-parser middleware) - */ - cookies?: Record; - /** - * Headers from the request - */ - headers?: Record; -} - -/** - * Minimal Express Response interface for type safety without requiring Express as a dependency - * @public - */ -export interface ExpressResponse { - status(code: number): ExpressResponse; - json(data: unknown): void; - /** - * Send a raw response body (string, Buffer, null, etc.). Used for HTML - * responses (editing render endpoint) and 204 no-content replies. - */ - send?(body: unknown): void; - /** - * Set a response header. Used by editing middleware to apply CORS / CSP - * headers without depending on Express types directly. - */ - setHeader?(name: string, value: string | string[]): void; -} - -/** - * Configuration for server-side data handlers - * @public - */ -export interface DataHandlerConfig { - /** - * The endpoint path for the data handler. - * @default '/_data' - */ - endpoint?: string; -} - -/** - * Express next function type - * @public - */ -export type ExpressNextFunction = (error?: unknown) => void; - -/** - * Express-compatible middleware type - * @public - */ -export type ExpressMiddleware = ( - req: ExpressRequest, - res: ExpressResponse, - next: ExpressNextFunction -) => void | Promise; - -/** - * @public - */ -export type { LoaderRegistry } from '../loaders/loader-registry.token'; - -/** - * Options for the Express data handler - * @public - */ -export interface ExpressDataHandlerOptions extends DataHandlerConfig { - /** - * The shared loader registry (same object as 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. - * @internal - */ - extractRequestContext?: (req: ExpressRequest) => RequestContext; -} diff --git a/packages/angular/src/server/provide-server-loader-runner.ts b/packages/angular/src/server/provide-server-loader-runner.ts index 3f7450530f..6aa429aa0f 100644 --- a/packages/angular/src/server/provide-server-loader-runner.ts +++ b/packages/angular/src/server/provide-server-loader-runner.ts @@ -6,8 +6,9 @@ import { } 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 { LoaderCache, LoaderRunnerInit } from '../loaders/models'; import { ServerLoaderRunner } from './server-loader-runner'; +import { SITECORE_CONFIG_TOKEN } from '../lib/tokens'; /** * Wires SSR {@link SERVER_LOADER_RUNNER} to ServerLoaderRunner @@ -22,13 +23,14 @@ export function provideServerLoaderRunner(): EnvironmentProviders { provide: SERVER_LOADER_RUNNER, useFactory: () => { const registry = inject(LOADER_REGISTRY); + const config = inject(SITECORE_CONFIG_TOKEN); return { - resolve(request: LoaderApiRequest) { + resolve(request: LoaderRunnerInit) { const ssrContext = inject(REQUEST_CONTEXT, { optional: true }) as | { cache?: LoaderCache } | undefined; const cache = ssrContext?.cache; - return new ServerLoaderRunner(registry, cache).resolve(request); + return new ServerLoaderRunner(registry, config, 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 index e4861b8202..6e87b91b2e 100644 --- a/packages/angular/src/server/server-loader-runner.spec.ts +++ b/packages/angular/src/server/server-loader-runner.spec.ts @@ -1,11 +1,18 @@ /* 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 type { LoaderCache, LoaderFn, LoaderRunnerInit } from '../loaders/models'; import { createLoaderCache } from './cache/loader-cache'; import { buildCacheKey } from './cache/cache-key'; +import { + mockAngularSitecoreConfig, + makeLoaderContext, + mockScParams, +} from '../testing/loader-spec-helpers'; describe('ServerLoaderRunner', () => { + const mockConfig = mockAngularSitecoreConfig(); + const demoConfig = mockAngularSitecoreConfig({ defaultSite: 'demo' }); const pageLoader: LoaderFn = vi.fn().mockResolvedValue({ title: 'Page' }); beforeEach(async () => { @@ -14,12 +21,13 @@ describe('ServerLoaderRunner', () => { }); it('should return error when loader id is not in registry', async () => { - const provider = new ServerLoaderRunner({}); + const provider = new ServerLoaderRunner({}, mockConfig); const result = await provider.resolve({ loaderId: 'missing', url: '/path', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(result).toEqual({ kind: 'error', @@ -29,19 +37,23 @@ describe('ServerLoaderRunner', () => { }); it('should invoke loader and return data on cache miss', async () => { - const provider = new ServerLoaderRunner({ page: pageLoader }); + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig); const result = await provider.resolve({ loaderId: 'page', url: '/about', - params: { slug: 'about' }, + routeParams: { slug: 'about' }, query: { q: '1' }, + csdkRequestData: null, }); expect(pageLoader).toHaveBeenCalledWith({ url: '/about', - params: { slug: 'about' }, + routeParams: { slug: 'about' }, query: { q: '1' }, - requestContext: undefined, + scParams: { + siteName: 'default', + }, + csdkRequestData: undefined, }); expect(result).toEqual({ kind: 'data', data: { title: 'Page' } }); }); @@ -59,12 +71,13 @@ describe('ServerLoaderRunner', () => { config: {}, }; - const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig, cache); const result = await provider.resolve({ loaderId: 'page', url: '/cached', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(result).toEqual({ kind: 'data', data: { cached: true } }); @@ -76,12 +89,13 @@ describe('ServerLoaderRunner', () => { loaderRedirectTarget: '/other', status: 302, }); - const provider = new ServerLoaderRunner({ page: pageLoader }); + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig); const result = await provider.resolve({ loaderId: 'page', url: '/redirect', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(result).toEqual({ @@ -93,12 +107,13 @@ describe('ServerLoaderRunner', () => { 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 provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig); const result = await provider.resolve({ loaderId: 'page', url: '/fail', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(result.kind).toBe('error'); @@ -121,12 +136,13 @@ describe('ServerLoaderRunner', () => { config: {}, }; - const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig, cache); await provider.resolve({ loaderId: 'page', url: '/store', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(cache.set).toHaveBeenCalled(); @@ -145,19 +161,16 @@ describe('ServerLoaderRunner', () => { config: {}, }; - const provider = new ServerLoaderRunner({ page: pageLoader }, cache); - await provider.resolve({ - loaderId: 'page', - url: '/live', - params: {}, - query: {}, - }); - await provider.resolve({ + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig, cache); + const payload = { loaderId: 'page', url: '/live', - params: {}, + routeParams: {}, query: {}, - }); + csdkRequestData: null, + }; + await provider.resolve(payload); + await provider.resolve(payload); expect(pageLoader).toHaveBeenCalledTimes(2); expect(cache.get).not.toHaveBeenCalled(); @@ -166,12 +179,13 @@ describe('ServerLoaderRunner', () => { 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 = { + const provider = new ServerLoaderRunner({ page: pageLoader }, demoConfig, cache); + const request: LoaderRunnerInit = { loaderId: 'page', url: '/featured', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, cacheOptions: { enabled: true, tags: ['featured'], revalidate: 60 }, }; @@ -184,12 +198,13 @@ describe('ServerLoaderRunner', () => { 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 = { + const provider = new ServerLoaderRunner({ page: pageLoader }, demoConfig, cache); + const request: LoaderRunnerInit = { loaderId: 'page', url: '/ttl-override', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, cacheOptions: { enabled: true, revalidate: 60 }, }; @@ -208,12 +223,13 @@ describe('ServerLoaderRunner', () => { 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 = { + const provider = new ServerLoaderRunner({ page: pageLoader }, demoConfig, cache); + const request: LoaderRunnerInit = { loaderId: 'page', url: '/tagged', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, cacheOptions: { enabled: true, tags: ['featured', 'campaign-x'] }, }; @@ -244,12 +260,13 @@ describe('ServerLoaderRunner', () => { config: {}, }; - const provider = new ServerLoaderRunner({ page: pageLoader }, cache); + const provider = new ServerLoaderRunner({ page: pageLoader }, mockConfig, cache); const result = await provider.resolve({ loaderId: 'page', url: '/protected', - params: {}, + routeParams: {}, query: {}, + csdkRequestData: null, }); expect(result.kind).toBe('redirect'); @@ -260,20 +277,25 @@ describe('ServerLoaderRunner', () => { 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 = { + const provider = new ServerLoaderRunner({ page: loader }, demoConfig, cache); + const request: LoaderRunnerInit = { loaderId: 'page', url: '/about', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, }; await provider.resolve(request); - const { key } = buildCacheKey('page', { - url: request.url, - params: request.params, - query: request.query, - }); + const { key } = buildCacheKey( + 'page', + makeLoaderContext({ + url: request.url, + routeParams: request.routeParams, + query: request.query, + scParams: mockScParams({ siteName: 'demo' }), + }) + ); await cache.invalidate({ tags: [key] }); const staleResult = await provider.resolve(request); @@ -294,20 +316,25 @@ describe('ServerLoaderRunner', () => { 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 = { + const provider = new ServerLoaderRunner({ page: loader }, demoConfig, cache); + const request: LoaderRunnerInit = { loaderId: 'page', url: '/coalesce', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, }; await provider.resolve(request); - const { key } = buildCacheKey('page', { - url: request.url, - params: request.params, - query: request.query, - }); + const { key } = buildCacheKey( + 'page', + makeLoaderContext({ + url: request.url, + routeParams: request.routeParams, + query: request.query, + scParams: mockScParams({ siteName: 'demo' }), + }) + ); await cache.invalidate({ tags: [key] }); await Promise.all([provider.resolve(request), provider.resolve(request)]); @@ -331,12 +358,13 @@ describe('ServerLoaderRunner', () => { config: {}, }; - const provider = new ServerLoaderRunner({ page: loader }, cache); + const provider = new ServerLoaderRunner({ page: loader }, demoConfig, cache); const result = await provider.resolve({ loaderId: 'page', url: '/warn', - params: { site: 'demo', locale: 'en' }, + routeParams: { locale: 'en' }, query: {}, + csdkRequestData: null, }); expect(result).toEqual({ kind: 'data', data: { title: 'v1' } }); diff --git a/packages/angular/src/server/server-loader-runner.ts b/packages/angular/src/server/server-loader-runner.ts index 78331ba415..0c00c8b7ae 100644 --- a/packages/angular/src/server/server-loader-runner.ts +++ b/packages/angular/src/server/server-loader-runner.ts @@ -1,5 +1,5 @@ import { - LoaderApiRequest, + LoaderRunnerInit, LoaderContext, isLoaderRedirectResult, LoaderCache, @@ -8,6 +8,7 @@ import { import { LoaderRegistry } from '../loaders/loader-registry.token'; import { buildCacheKey } from './cache/cache-key'; import { buildLoaderCacheTags } from './cache/cache-tags'; +import { AngularSitecoreConfig } from '../config/define-config'; /** * Server-side cache aware loader data resolver. @@ -30,23 +31,45 @@ export class ServerLoaderRunner { /** * @param {LoaderRegistry} registry - Same loader map as `provideLoaderRegistry` / `/_data` middleware. + * @param {AngularSitecoreConfig} config - Resolved Sitecore configuration (drives default site/locale). * @param {LoaderCache | undefined} cache - Optional cache instance from createLoaderCache. */ - constructor(private readonly registry: LoaderRegistry, private readonly cache?: LoaderCache) {} + constructor( + private readonly registry: LoaderRegistry, + private readonly config: AngularSitecoreConfig, + 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. + * @param {LoaderRunnerInit} init - Loader id, URL, params, server-derived request data, 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; + async resolve(init: LoaderRunnerInit): Promise { + const { loaderId, url, routeParams, query, cacheOptions, csdkRequestData } = init; 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 defaultScParams = { + siteName: this.config.defaultSite, + }; + + const scParams = { + ...defaultScParams, + ...(csdkRequestData?.scParams || {}), + }; + + // ctx carries everything the loader and cache key need; only loaderId travels + // alongside it, so the init object is not passed any further. + const ctx: LoaderContext = { + url, + routeParams, + query, + scParams, + csdkRequestData: csdkRequestData ?? undefined, + }; const cacheable = this.cache && (cacheOptions?.enabled ?? this.cache.enabled()); @@ -59,33 +82,33 @@ export class ServerLoaderRunner { } if (read.kind === 'stale') { - this.scheduleBackgroundRefresh(request, ctx, key, cacheOptions); + this.scheduleBackgroundRefresh(loaderId, ctx, key, cacheOptions); return { kind: 'data', data: read.value }; } } - return this.runLoader({ request, ctx, cacheable: !!cacheable, cacheOptions }); + return this.runLoader({ loaderId, 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 {string} loaderId - The loader id * @param {LoaderContext} ctx - The loader context * @param {string} cacheKey - The cache key - * @param {LoaderApiRequest['cacheOptions']} cacheOptions - The cache options + * @param {LoaderRunnerInit['cacheOptions']} cacheOptions - The cache options */ private scheduleBackgroundRefresh( - request: LoaderApiRequest, + loaderId: string, ctx: LoaderContext, cacheKey: string, - cacheOptions: LoaderApiRequest['cacheOptions'] + cacheOptions: LoaderRunnerInit['cacheOptions'] ): void { if (ServerLoaderRunner.pendingCacheOps.has(cacheKey)) { return; } ServerLoaderRunner.pendingCacheOps.add(cacheKey); void this.runLoader({ - request, + loaderId, ctx, cacheable: true, cacheOptions, @@ -101,19 +124,18 @@ export class ServerLoaderRunner { } private async runLoader({ - request, + loaderId, ctx, cacheable, cacheOptions, knownCacheKey, }: { - request: LoaderApiRequest; + loaderId: string; ctx: LoaderContext; cacheable: boolean; - cacheOptions?: LoaderApiRequest['cacheOptions']; + cacheOptions?: LoaderRunnerInit['cacheOptions']; knownCacheKey?: string; }): Promise { - const { loaderId } = request; const loader = this.registry[loaderId]!; let value: unknown; diff --git a/packages/angular/src/server/utils.spec.ts b/packages/angular/src/server/utils.spec.ts new file mode 100644 index 0000000000..409185a919 --- /dev/null +++ b/packages/angular/src/server/utils.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { PREVIEW_KEY } from '@sitecore-content-sdk/content/editing'; +import { matches, isEditingPreview } from './utils'; + +describe('matches', () => { + describe('string patterns (exact match)', () => { + it('matches an identical path', () => { + expect(matches('/about', '/about')).toBe(true); + }); + + it('does not match a different path', () => { + expect(matches('/about', '/contact')).toBe(false); + }); + + it('does not match on a trailing slash difference', () => { + expect(matches('/about/', '/about')).toBe(false); + }); + + it('does not match a sub-path (no prefix semantics)', () => { + expect(matches('/about/team', '/about')).toBe(false); + }); + }); + + describe('regex patterns', () => { + it('matches when the regex tests true', () => { + expect(matches('/api/data', /^\/api(\/|$)/)).toBe(true); + expect(matches('/api', /^\/api(\/|$)/)).toBe(true); + }); + + it('is anchored: /api regex does not match /api-docs', () => { + expect(matches('/api-docs', /^\/api(\/|$)/)).toBe(false); + }); + + it('matches a static-file path via an extension regex', () => { + expect(matches('/assets/logo.png', /\.[^/]+$/)).toBe(true); + expect(matches('/about', /\.[^/]+$/)).toBe(false); + }); + }); +}); + +describe('isEditingPreview', () => { + it('returns false when no cookies are provided', () => { + expect(isEditingPreview()).toBe(false); + }); + + it('returns false when the preview cookie is absent', () => { + expect(isEditingPreview({ other: '1' })).toBe(false); + }); + + it('returns true when the preview cookie is present', () => { + expect(isEditingPreview({ [PREVIEW_KEY]: 'true' })).toBe(true); + }); +}); diff --git a/packages/angular/src/server/utils.ts b/packages/angular/src/server/utils.ts index c7e065b64d..cb0a161ed8 100644 --- a/packages/angular/src/server/utils.ts +++ b/packages/angular/src/server/utils.ts @@ -1,3 +1,5 @@ +import { PREVIEW_KEY } from '@sitecore-content-sdk/content/editing'; + /** * Reads `process.env` when running under Node; otherwise returns an empty object. * process.env is only available on the server in Angular @@ -14,3 +16,31 @@ export function readProcessEnv(name: string) { } return undefined; } + +/** + * A middleware path pattern: a `string` (matched exactly) or a `RegExp` (matched with `.test`). + * @public + */ +export type PathPattern = string | RegExp; + +/** + * Matches a request path against a single pattern. + * A `string` pattern is compared for exact equality; a `RegExp` pattern is tested against the path. + * @param {string} path - The request path to test (query string already stripped). + * @param {PathPattern} pattern - Exact string or regular expression. + * @returns {boolean} True if the path matches the pattern. + * @internal + */ +export function matches(path: string, pattern: PathPattern): boolean { + return typeof pattern === 'string' ? path === pattern : pattern.test(path); +} + +/** + * Check if a request is in editing/preview mode. + * @param {Record} cookies - Request cookies + * @returns {boolean} True if editing or preview mode is active + * @internal + */ +export function isEditingPreview(cookies: Record = {}): boolean { + return !!cookies[PREVIEW_KEY]; +} diff --git a/packages/angular/src/testing/loader-spec-helpers.ts b/packages/angular/src/testing/loader-spec-helpers.ts new file mode 100644 index 0000000000..d5c8329ff8 --- /dev/null +++ b/packages/angular/src/testing/loader-spec-helpers.ts @@ -0,0 +1,50 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable jsdoc/require-param */ +import type { AngularSitecoreConfig } from '../config/define-config'; +import type { CsdkRequestParams, LoaderContext, LoaderPayload } from '../loaders/models'; + +/** Minimal resolved Angular sitecore config for unit tests. */ +export function mockAngularSitecoreConfig( + overrides: Partial = {} +): AngularSitecoreConfig { + return { + defaultSite: 'default', + defaultLanguage: 'en', + angular: { + locales: ['en'], + loadersCache: { enabled: true, revalidate: 300 }, + }, + ...overrides, + } as AngularSitecoreConfig; +} + +/** Default Content SDK request params for loader/cache tests. */ +export function mockScParams(overrides: Partial = {}) { + return { + siteName: 'default', + ...overrides, + }; +} + +/** Build a {@link LoaderContext} with post-refactor field names and defaults. */ +export function makeLoaderContext(overrides: Partial = {}): LoaderContext { + const { scParams, ...rest } = overrides; + return { + url: '/about', + routeParams: { locale: 'en' }, + query: {}, + scParams: mockScParams(scParams), + ...rest, + }; +} + +/** Build a {@link LoaderPayload} with required fields for client loader tests. */ +export function makeLoaderPayload(overrides: Partial = {}): LoaderPayload { + return { + loaderId: 'page', + url: '/test', + routeParams: {}, + query: {}, + ...overrides, + }; +} diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json index 5b1b30a5cf..3869fbdd36 100644 --- a/packages/angular/tsconfig.json +++ b/packages/angular/tsconfig.json @@ -5,7 +5,7 @@ "module": "es2022", "moduleResolution": "bundler", "strictPropertyInitialization": false, - "typeRoots": ["./node_modules/@types", "./typings"], + "types": ["node"], "skipLibCheck": true }, "exclude": ["./src/**/*.spec.ts", "./src/server/config-cli/**/*", "./src/server/tools/**/*"] diff --git a/packages/content/api/content-sdk-content.api.md b/packages/content/api/content-sdk-content.api.md index 90a63b4ce6..9775c8424d 100644 --- a/packages/content/api/content-sdk-content.api.md +++ b/packages/content/api/content-sdk-content.api.md @@ -616,6 +616,9 @@ export function getFieldValue(renderingOrFields: ComponentRendering | Compone // @public export function getGroomedVariantIds(variantIds: string[]): PersonalizedRewriteData; +// @internal +export function getHostnameFromHostHeader(host: string): string; + // @internal export function getImportMapInfo(importMap: ImportEntry[]): ImportEntryInfo[]; @@ -1435,7 +1438,7 @@ export type StaticPath = { }; // @internal -const subscribeToFormSubmitEvent: (formElement: HTMLElement, componentId?: string) => void; +const subscribeToFormSubmitEvent: (formElement: HTMLElement, componentId?: string, signal?: AbortSignal) => void; // @public export interface TextField extends FieldMetadata { diff --git a/packages/content/src/form/form.test.ts b/packages/content/src/form/form.test.ts index 63d69cc0b3..c76ee71ca7 100644 --- a/packages/content/src/form/form.test.ts +++ b/packages/content/src/form/form.test.ts @@ -103,5 +103,48 @@ describe('form', () => { expect(formEvent.calledWith('formId', 'SUBMITTED', 'componentId')); }); + + it('should pass AbortSignal to addEventListener and stop handling events after abort', () => { + const formEl = global.document.createElement('form'); + global.document.body.appendChild(formEl); + + const formEvent = sinon.stub(); + const { subscribeToFormSubmitEvent: subscribeWithSignal } = proxyquire('./form', { + '@sitecore-content-sdk/events': { + form: formEvent, + }, + }); + + const controller = new dom.window.AbortController(); + const addEventListenerSpy = sinon.spy(formEl, 'addEventListener'); + + subscribeWithSignal(formEl, 'component-id', controller.signal); + + expect(addEventListenerSpy.calledOnce).to.be.true; + expect( + addEventListenerSpy.calledWith( + 'form:engage', + sinon.match.func, + sinon.match({ signal: controller.signal }) + ) + ).to.be.true; + + formEl.dispatchEvent( + new dom.window.CustomEvent('form:engage', { + detail: { formId: 'formId', name: 'VIEWED' }, + }) + ); + expect(formEvent.calledOnce).to.be.true; + expect(formEvent.calledWith('formId', 'VIEWED', 'componentid')).to.be.true; + + controller.abort(); + + formEl.dispatchEvent( + new dom.window.CustomEvent('form:engage', { + detail: { formId: 'formId', name: 'SUBMITTED' }, + }) + ); + expect(formEvent.calledOnce).to.be.true; + }); }); }); diff --git a/packages/content/src/form/form.ts b/packages/content/src/form/form.ts index 401f9cdb23..89a21273ab 100644 --- a/packages/content/src/form/form.ts +++ b/packages/content/src/form/form.ts @@ -79,16 +79,22 @@ export const executeScriptElements = (rootElement: HTMLElement) => { * @param {string} [componentId] - The unique identifier of the component * @internal */ -export const subscribeToFormSubmitEvent = (formElement: HTMLElement, componentId?: string) => { - formElement.addEventListener('form:engage', (( - e: CustomEvent<{ formId: string; name: 'VIEWED' | 'SUBMITTED' }> - ) => { - const { formId, name } = e.detail; - - if (formId && name) { - debug.form('Sending form event', formId, name); - - form(formId, name, componentId?.replace(/-/g, '') || ''); - } - }) as EventListener); +export const subscribeToFormSubmitEvent = ( + formElement: HTMLElement, + componentId?: string, + signal?: AbortSignal +) => { + formElement.addEventListener( + 'form:engage', + ((e: CustomEvent<{ formId: string; name: 'VIEWED' | 'SUBMITTED' }>) => { + const { formId, name } = e.detail; + + if (formId && name) { + debug.form('Sending form event', formId, name); + + form(formId, name, componentId?.replace(/-/g, '') || ''); + } + }) as EventListener, + { signal } + ); }; diff --git a/packages/content/src/site/index.ts b/packages/content/src/site/index.ts index 91f5d41252..86e5981765 100644 --- a/packages/content/src/site/index.ts +++ b/packages/content/src/site/index.ts @@ -29,5 +29,6 @@ export { SiteRewriteData, SITE_PREFIX, SITE_KEY, + getHostnameFromHostHeader, } from './utils'; export { SiteResolver } from './site-resolver'; diff --git a/packages/content/src/site/utils.test.ts b/packages/content/src/site/utils.test.ts index aba4071b6f..bfa77072f7 100644 --- a/packages/content/src/site/utils.test.ts +++ b/packages/content/src/site/utils.test.ts @@ -1,7 +1,59 @@ import { expect } from 'chai'; -import { getSiteRewrite, getSiteRewriteData, normalizeSiteRewrite, SITE_PREFIX } from './utils'; +import { + getHostnameFromHostHeader, + getSiteRewrite, + getSiteRewriteData, + normalizeSiteRewrite, + SITE_PREFIX, +} from './utils'; describe('utils', () => { + describe('getHostnameFromHostHeader', () => { + it('should strip port from bracketed IPv6 host', () => { + expect(getHostnameFromHostHeader('[::1]:3000')).to.equal('::1'); + }); + + it('should return bracketed IPv6 without port', () => { + expect(getHostnameFromHostHeader('[2001:db8::1]')).to.equal('2001:db8::1'); + }); + + it('should strip port from IPv4 host', () => { + expect(getHostnameFromHostHeader('127.0.0.1:3000')).to.equal('127.0.0.1'); + }); + + it('should strip port from DNS hostname', () => { + expect(getHostnameFromHostHeader('example.com:443')).to.equal('example.com'); + }); + + it('should not treat trailing digits after colon as port for unbracketed IPv6', () => { + expect(getHostnameFromHostHeader('::1')).to.equal('::1'); + }); + + it('should preserve unbracketed IPv6 addresses', () => { + expect(getHostnameFromHostHeader('2001:db8::1')).to.equal('2001:db8::1'); + }); + + it('should lowercase hostnames', () => { + expect(getHostnameFromHostHeader('Example.COM:8080')).to.equal('example.com'); + }); + + it('should return hostname unchanged when no port is present', () => { + expect(getHostnameFromHostHeader('localhost')).to.equal('localhost'); + }); + + it('should trim surrounding whitespace', () => { + expect(getHostnameFromHostHeader(' example.com:443 ')).to.equal('example.com'); + }); + + it('should not strip colon suffix when it is not a numeric port', () => { + expect(getHostnameFromHostHeader('host:name')).to.equal('host:name'); + }); + + it('should handle malformed bracketed IPv6 by lowercasing the raw value', () => { + expect(getHostnameFromHostHeader('[::1')).to.equal('[::1'); + }); + }); + describe('getSiteRewrite', () => { const data = { siteName: 'content-sdk', diff --git a/packages/content/src/site/utils.ts b/packages/content/src/site/utils.ts index 38eb706d78..c2296f268c 100644 --- a/packages/content/src/site/utils.ts +++ b/packages/content/src/site/utils.ts @@ -18,6 +18,44 @@ export type SiteRewriteData = { siteName: string; }; +/** + * Hostname from a `Host` or `x-forwarded-host` value, without port. + * - `[::1]:3000` → `::1` + * - `127.0.0.1:3000` → `127.0.0.1` + * - `example.com:443` → `example.com` + * - `::1` → `::1` (does not treat `:1` as a port) + * @param {string} host - Raw header value + * @returns {string} The hostname + * @internal + */ +export function getHostnameFromHostHeader(host: string): string { + const trimmed = host.trim(); + + // Bracketed IPv6: "[...]:port" or "[...]" + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end).toLowerCase(); + } + } + + // Unbracketed IPv6 (e.g. ::1, 2001:db8::1) — never strip on last ":digits" + if (trimmed.includes('::')) { + return trimmed.toLowerCase(); + } + + // IPv4 or DNS name with ":port" (port = decimal digits only) + const lastColon = trimmed.lastIndexOf(':'); + if (lastColon > 0) { + const after = trimmed.slice(lastColon + 1); + if (/^\d+$/.test(after)) { + return trimmed.slice(0, lastColon).toLowerCase(); + } + } + + return trimmed.toLowerCase(); +} + /** * Get a site rewrite path for given pathname * @param {string} pathname the pathname 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 0012e3f719..0f12394391 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.env.example +++ b/packages/create-content-sdk-app/src/templates/angular/.env.example @@ -18,3 +18,8 @@ # Loader cache (server only; see src/server.ts) # LOADER_CACHE_DRIVER=unstorage-memory # LOADER_CACHE_DRIVER=unstorage-fs +# Timeout (ms) for Sitecore CDP requests to respond within. Default is 400. +# PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT= + +# Timeout (ms) for Sitecore Experience Edge requests to respond within. Default is 400. +# PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT= diff --git a/packages/create-content-sdk-app/src/templates/angular/.env.prod.example b/packages/create-content-sdk-app/src/templates/angular/.env.prod.example index 5efb35e74d..9d4c8acd01 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.env.prod.example +++ b/packages/create-content-sdk-app/src/templates/angular/.env.prod.example @@ -1,2 +1,7 @@ # Production overrides. Merged after `.env` when running `npm run gen:env:prod`. # Copy to `.env.prod` — file is gitignored. +# Timeout (ms) for Sitecore CDP requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT= + +# Timeout (ms) for Sitecore Experience Edge requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT= diff --git a/packages/create-content-sdk-app/src/templates/angular/.gitignore b/packages/create-content-sdk-app/src/templates/angular/.gitignore index 28ea6991ce..0367920e65 100644 --- a/packages/create-content-sdk-app/src/templates/angular/.gitignore +++ b/packages/create-content-sdk-app/src/templates/angular/.gitignore @@ -40,6 +40,9 @@ yarn-error.log # Loader cache (unstorage fs driver) .cache/ +# sitecore temp files (generated by sitecore-tools build) +.sitecore/* + # Miscellaneous /.angular/cache .sass-cache/ diff --git a/packages/create-content-sdk-app/src/templates/angular/sitecore.cli.config.ts b/packages/create-content-sdk-app/src/templates/angular/sitecore.cli.config.ts index 0f370d1e99..36b20cdd03 100644 --- a/packages/create-content-sdk-app/src/templates/angular/sitecore.cli.config.ts +++ b/packages/create-content-sdk-app/src/templates/angular/sitecore.cli.config.ts @@ -1,11 +1,17 @@ -import { defineCliConfig, generateMetadata } from '@sitecore-content-sdk/angular/config-cli'; +import { + defineCliConfig, + generateMetadata, + generateSites, +} from '@sitecore-content-sdk/angular/config-cli'; +import scConfig from './sitecore.config'; /** * Sitecore CLI configuration (Node / build-time only). This file is not part of the Angular * compiler `include` set and is only loaded by `sitecore-tools`. */ export default defineCliConfig({ + config: scConfig, build: { - commands: [generateMetadata()], + commands: [generateMetadata(), generateSites()], }, componentMap: { paths: ['src/app/components'], diff --git a/packages/create-content-sdk-app/src/templates/angular/sitecore.config.ts b/packages/create-content-sdk-app/src/templates/angular/sitecore.config.ts index 8ec20f2cbe..fc02d1a719 100644 --- a/packages/create-content-sdk-app/src/templates/angular/sitecore.config.ts +++ b/packages/create-content-sdk-app/src/templates/angular/sitecore.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from '@sitecore-content-sdk/angular'; +import { defineConfig } from '@sitecore-content-sdk/angular/config'; import { environment } from './src/environments/environment'; /** 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 index fd7ccd1e56..083f32f3ef 100644 --- 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 @@ -30,7 +30,7 @@ interface ConfigResponse { const ADMIN_BASE = '/api/_cache'; /** - * Demo page that lists loader-cache entries and supports tag-based invalidation (Phase 3 OSR). + * Demo page that lists loader-cache entries and supports tag-based invalidation. */ @Component({ selector: 'app-cache-demo', diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.html b/packages/create-content-sdk-app/src/templates/angular/src/app/app.html index 42c8dc7510..800e6fb945 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.html +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.html @@ -1,2 +1,3 @@ + diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/app.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/app.ts index e607c19ecf..083aeeb0fb 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/app/app.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/app.ts @@ -1,11 +1,12 @@ import { Component, effect, inject, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { CdpPageViewComponent } from './components/content-sdk/cdp-page-view.component'; import { ScEditingScriptsComponent, SitecoreContextService } from '@sitecore-content-sdk/angular'; import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ScEditingScriptsComponent], + imports: [RouterOutlet, ScEditingScriptsComponent, CdpPageViewComponent], templateUrl: './app.html', styleUrl: './app.css', }) diff --git a/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/cdp-page-view.component.ts b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/cdp-page-view.component.ts new file mode 100644 index 0000000000..adf1a41642 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/angular/src/app/components/content-sdk/cdp-page-view.component.ts @@ -0,0 +1,58 @@ +import { Component, effect, inject } from '@angular/core'; +import { + CdpHelper, + SITECORE_ANALYTICS, + SitecoreContextService, +} from '@sitecore-content-sdk/angular'; +import config from '../../../../sitecore.config'; + +/** + * CDP page view component. Dispatches a Sitecore CDP page view event on initial load and on + * every client-side navigation. Template-less — it only performs the side effect. + * + * page data comes from {@link SitecoreContextService}, dispatch goes through the {@link SITECORE_ANALYTICS} facade + * Runs in browser only. + * + * See Sitecore Content SDK documentation for details: + * https://www.npmjs.com/package/@sitecore-content-sdk/events + */ +@Component({ + selector: 'app-cdp-page-view', + template: '', +}) +export class CdpPageViewComponent { + private readonly analytics = inject(SITECORE_ANALYTICS); + private readonly context = inject(SitecoreContextService); + + constructor() { + effect(() => { + const page = this.context.page(); + // Do not create events in editing or preview mode, or if missing route data. + if (!page || !page.mode.isNormal) { + return; + } + const route = page.layout.sitecore.route; + if (!route?.itemId) { + return; + } + + const language = page.locale || config.defaultLanguage; + const pageVariantId = CdpHelper.getPageVariantId( + route.itemId, + language, + page.layout.sitecore.context.variantId as string, + config.personalize.scope + ); + + // Events may not be initialized (the façade is a no-op in dev / when Edge config is + // missing, and swallows runtime failures at debug level). + void this.analytics.pageView({ + channel: 'WEB', + currency: 'USD', + page: route.name, + pageVariantId, + language, + }); + }); + } +} \ No newline at end of file diff --git a/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts b/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts index a0c4e7f95c..f6e10e3000 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/dictionary.loader.ts @@ -1,4 +1,5 @@ import type { LoaderFn } from '@sitecore-content-sdk/angular'; +import { getLanguage, getSiteName } from '@sitecore-content-sdk/angular'; import type { DictionaryPhrases } from '@sitecore-content-sdk/content/i18n'; import { getClient } from '../client/sitecore-client'; @@ -6,5 +7,8 @@ import { getClient } from '../client/sitecore-client'; * Dictionary loader: fetches dictionary phrases from Sitecore for the current site/locale. */ export const dictionaryLoader: LoaderFn = async (context) => { - return await getClient().getDictionary({ locale: context.params['locale'] as string }); + return await getClient().getDictionary({ + locale: getLanguage(context), + site: getSiteName(context), + }); }; diff --git a/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/page.loader.ts b/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/page.loader.ts index d482983ca2..acac2db0b0 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/page.loader.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/content-sdk/loaders/page.loader.ts @@ -2,6 +2,10 @@ import type { LoaderFn, Page } from '@sitecore-content-sdk/angular'; import { NotFoundNavigationError, getEditingPreviewData, + getSiteName, + getVariantId, + getComponentVariantIds, + getLanguage, splitLocaleFromPath, } from '@sitecore-content-sdk/angular'; import scConfig from '../../../sitecore.config'; @@ -12,14 +16,20 @@ import { getClient } from '../client/sitecore-client'; * Uses imported config and {@link getClient} so this runs outside Angular injection context. */ export const pageLoader: LoaderFn = async (context) => { - const previewData = getEditingPreviewData(context.requestContext); - const locale = (context.params['locale'] as string | undefined) || scConfig.defaultLanguage; + const previewData = getEditingPreviewData(context.csdkRequestData); + const locale = getLanguage(context) || scConfig.defaultLanguage; const { nonLocalePath } = splitLocaleFromPath(context.url, scConfig.angular.locales); - const site = scConfig.defaultSite; const page = previewData ? await getClient().getPreview(previewData) - : await getClient().getPage(nonLocalePath, { locale, site }); + : await getClient().getPage(nonLocalePath, { + locale, + site: getSiteName(context), + personalize: { + variantId: getVariantId(context), + componentVariantIds: getComponentVariantIds(context), + }, + }); if (!page) { throw new NotFoundNavigationError(); 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 3dbfa061af..0fbfd0dfd7 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 @@ -15,10 +15,13 @@ import { createEditingRenderMiddleware, createLoaderCache, createLoaderDataServiceMiddleware, + createMultisiteMiddleware, + createPersonalizeMiddleware, createSitecoreRevalidateMiddleware, } from '@sitecore-content-sdk/angular'; import { LOADERS } from './content-sdk/loaders'; import { componentMap } from '.sitecore/component-map'; +import sites from '.sitecore/sites.json'; import config from '../sitecore.config'; const browserDistFolder = join(import.meta.dirname, '../browser'); @@ -57,13 +60,7 @@ app.use( createSitecoreRevalidateMiddleware({ cache: loaderCache, defaultLocale: config.defaultLanguage, - sites: [ - { - name: config.defaultSite, - hostName: '*', - language: config.defaultLanguage, - }, - ], + sites, }) ); @@ -89,11 +86,61 @@ app.use( */ app.use(createEditingRenderMiddleware()); +/** + * Shared path matcher for the request-scoped middlewares (multisite + personalize, and any + * future redirects middleware). It decides which requests these middlewares act on. + * + * Patterns are exact strings or RegExp. The SDK already skips API routes (`/api/*`), Sitecore + * routes (`/sitecore/*`), static files (any path whose last segment has an extension) and + * editing/preview requests by default, so only list app-specific routes here. + * + * excludePaths — additionally never processed + * includePaths — when set, ONLY matching paths are processed (everything else is skipped) + */ +const middlewareMatcher = { + excludePaths: ['/healthz', '/metrics', /\.[^/]+$/], + // includePaths: [/^\/[a-z]{2}(-[A-Z]{2})?(\/|$)/], // e.g. restrict to locale-prefixed routes +}; + +/** + * Multisite middleware. Resolves the site for each request (sc_site query → cookie → + * hostname → default) from the generated site list and writes it onto `req.scParams` + * for downstream loaders and the loader cache key. Must run before the personalize + * middleware, which reads the resolved site. + */ +app.use( + createMultisiteMiddleware({ + ...config.multisite, + sites, + defaultSite: config.defaultSite, + matcher: middlewareMatcher, + }) +); + +/** + * Personalize middleware. Identifies page/component variants for the request via + * Sitecore CDP and writes them onto `req.scParams` so the page loader fetches the + * personalized layout and the loader cache keys per variant. + * + * NOTE: Personalize requires Edge configuration (contextId/clientContextId) and + * cannot work with local containers + */ +app.use( + createPersonalizeMiddleware({ + ...config.personalize, + ...config.api.edge, + locales: config.angular.locales, + defaultLanguage: config.defaultLanguage, + defaultSite: config.defaultSite, + matcher: middlewareMatcher, + }) +); + /** * 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(createLoaderDataServiceMiddleware({ loaders: LOADERS, cache: loaderCache })); +app.use(createLoaderDataServiceMiddleware(config, { loaders: LOADERS, cache: loaderCache })); /** * Serve static files from /browser @@ -108,12 +155,12 @@ app.use( /** * Handle all other requests by rendering the Angular application. - * The cache reference rides on REQUEST_CONTEXT so the SSR loader resolver - * picks it up via inject(REQUEST_CONTEXT). + * The cache and the Node req/res ride on REQUEST_CONTEXT: the SSR loader resolver picks up the + * cache, and the server analytics provider uses req/res for cookie-based CDP event dispatch. */ app.use((req, res, next) => { angularApp - .handle(req, { cache: loaderCache }) + .handle(req, { cache: loaderCache, req, res }) .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) .catch((err) => { next(err); diff --git a/packages/nextjs/src/proxy/proxy.ts b/packages/nextjs/src/proxy/proxy.ts index 090f65712f..9fa11bc643 100644 --- a/packages/nextjs/src/proxy/proxy.ts +++ b/packages/nextjs/src/proxy/proxy.ts @@ -1,4 +1,9 @@ -import { SITE_KEY, SiteInfo, SiteResolver } from '@sitecore-content-sdk/content/site'; +import { + SITE_KEY, + SiteInfo, + SiteResolver, + getHostnameFromHostHeader, +} from '@sitecore-content-sdk/content/site'; import { GraphQLRequestClientFactory } from '@sitecore-content-sdk/core'; import { NextRequest, NextResponse } from 'next/server'; import { @@ -62,42 +67,6 @@ export abstract class ProxyHandler { ): Promise; } -/** - * Hostname from a `Host` or `x-forwarded-host` value, without port. - * - `[::1]:3000` → `::1` - * - `127.0.0.1:3000` → `127.0.0.1` - * - `example.com:443` → `example.com` - * - `::1` → `::1` (does not treat `:1` as a port) - * @param {string} host - Raw header value - */ -function getHostnameFromHostHeader(host: string): string { - const trimmed = host.trim(); - - // Bracketed IPv6: "[...]:port" or "[...]" - if (trimmed.startsWith('[')) { - const end = trimmed.indexOf(']'); - if (end !== -1) { - return trimmed.slice(1, end).toLowerCase(); - } - } - - // Unbracketed IPv6 (e.g. ::1, 2001:db8::1) — never strip on last ":digits" - if (trimmed.includes('::')) { - return trimmed.toLowerCase(); - } - - // IPv4 or DNS name with ":port" (port = decimal digits only) - const lastColon = trimmed.lastIndexOf(':'); - if (lastColon > 0) { - const after = trimmed.slice(lastColon + 1); - if (/^\d+$/.test(after)) { - return trimmed.slice(0, lastColon).toLowerCase(); - } - } - - return trimmed.toLowerCase(); -} - /** * Base proxy class with common methods * @public diff --git a/ref-docs/angular/loaders/README.md b/ref-docs/angular/loaders/README.md index 197ea4af21..5d82ec30d6 100644 --- a/ref-docs/angular/loaders/README.md +++ b/ref-docs/angular/loaders/README.md @@ -28,7 +28,7 @@ ## Type Aliases -- [LoaderApiRequest](type-aliases/LoaderApiRequest.md) +- [LoaderPayload](type-aliases/LoaderPayload.md) - [LoaderApiResponse](type-aliases/LoaderApiResponse.md) - [LoaderCacheReadResult](type-aliases/LoaderCacheReadResult.md) - [LoaderContext](type-aliases/LoaderContext.md) diff --git a/ref-docs/angular/loaders/interfaces/ServerLoaderRunnerPort.md b/ref-docs/angular/loaders/interfaces/ServerLoaderRunnerPort.md index b3f381b810..35a47b9955 100644 --- a/ref-docs/angular/loaders/interfaces/ServerLoaderRunnerPort.md +++ b/ref-docs/angular/loaders/interfaces/ServerLoaderRunnerPort.md @@ -26,7 +26,7 @@ Resolve loader data on the server (cache-aware) using the shared [LOADER\_REGIST | Parameter | Type | Description | | ------ | ------ | ------ | -| `request` | [`LoaderApiRequest`](../type-aliases/LoaderApiRequest.md) | Loader request payload | +| `request` | [`LoaderPayload`](../type-aliases/LoaderPayload.md) | Loader request payload | #### Returns diff --git a/ref-docs/angular/loaders/type-aliases/LoaderApiRequest.md b/ref-docs/angular/loaders/type-aliases/LoaderApiRequest.md index 764b570174..92e95202b5 100644 --- a/ref-docs/angular/loaders/type-aliases/LoaderApiRequest.md +++ b/ref-docs/angular/loaders/type-aliases/LoaderApiRequest.md @@ -2,11 +2,11 @@ *** -[@sitecore-content-sdk/angular](../../README.md) / [loaders](../README.md) / LoaderApiRequest +[@sitecore-content-sdk/angular](../../README.md) / [loaders](../README.md) / LoaderPayload -# Type Alias: LoaderApiRequest +# Type Alias: LoaderPayload -> **LoaderApiRequest** = `object` +> **LoaderPayload** = `object` Defined in: [packages/angular/src/loaders/models.ts:80](https://github.com/Sitecore/content-sdk/blob/27b90e02c7a030fc380d3d5e51ad2edbb3c50829/packages/angular/src/loaders/models.ts#L80) diff --git a/ref-docs/angular/server/express/classes/ServerLoaderRunner.md b/ref-docs/angular/server/express/classes/ServerLoaderRunner.md index 8842cdb4af..7bc816fddc 100644 --- a/ref-docs/angular/server/express/classes/ServerLoaderRunner.md +++ b/ref-docs/angular/server/express/classes/ServerLoaderRunner.md @@ -54,7 +54,7 @@ Resolve loader data with optional cache read-through and SWR refresh. | Parameter | Type | Description | | ------ | ------ | ------ | -| `request` | [`LoaderApiRequest`](../../../loaders/type-aliases/LoaderApiRequest.md) | Loader id, URL, params, optional request context and cache overrides. | +| `request` | [`LoaderPayload`](../../../loaders/type-aliases/LoaderPayload.md) | Loader id, URL, params, optional request context and cache overrides. | #### Returns diff --git a/ref-docs/angular/server/express/interfaces/ExpressDataHandlerOptions.md b/ref-docs/angular/server/express/interfaces/ExpressDataHandlerOptions.md index 6b3cbb2111..f35a45c5f5 100644 --- a/ref-docs/angular/server/express/interfaces/ExpressDataHandlerOptions.md +++ b/ref-docs/angular/server/express/interfaces/ExpressDataHandlerOptions.md @@ -47,9 +47,9 @@ The endpoint path for the data handler. *** -### extractRequestContext? +### extractRequestData? -> `optional` **extractRequestContext?**: (`req`) => [`RequestContext`](../../../loaders/interfaces/RequestContext.md) +> `optional` **extractRequestData?**: (`req`) => [`RequestContext`](../../../loaders/interfaces/RequestContext.md) Defined in: [packages/angular/src/server/models.ts:105](https://github.com/Sitecore/content-sdk/blob/27b90e02c7a030fc380d3d5e51ad2edbb3c50829/packages/angular/src/server/models.ts#L105) diff --git a/yarn.lock b/yarn.lock index c0485812df..00852c6626 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5209,8 +5209,11 @@ __metadata: "@angular/router": "npm:^21.1.0" "@eslint/js": "npm:^9.32.0" "@ngx-translate/core": "npm:^17.0.0" + "@sitecore-content-sdk/analytics-core": "npm:^2.1.0" "@sitecore-content-sdk/content": "npm:^2.1.0" "@sitecore-content-sdk/core": "npm:^2.1.0" + "@sitecore-content-sdk/events": "npm:^2.1.0" + "@sitecore-content-sdk/personalize": "npm:^2.1.0" "@types/node": "npm:^24.10.4" "@vitest/coverage-v8": "npm:^4.1.5" angular-eslint: "npm:^21.3.1" @@ -5232,6 +5235,9 @@ __metadata: "@angular/platform-server": ^21.0.0 "@angular/ssr": ^21.0.0 "@ngx-translate/core": ^17.0.0 + "@sitecore-content-sdk/analytics-core": ^2.1.0 + "@sitecore-content-sdk/events": ^2.1.0 + "@sitecore-content-sdk/personalize": ^2.1.0 zone.js: ^0.15.0 peerDependenciesMeta: "@angular/platform-server":