From d22c016c9feaf80aaa8ca69b785591c05445bfb8 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Tue, 23 Jun 2026 18:24:29 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=A5=20feat(web-sdk=5Fangular):=20A?= =?UTF-8?q?dd=20Angular=20Universal=20(SSR)=20support=20[NT-3467]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- implementations/web-sdk_angular/angular.json | 3 + implementations/web-sdk_angular/package.json | 7 + .../web-sdk_angular/pnpm-workspace.yaml | 1 + .../src/app/app.config.server.ts | 58 +++++++ .../web-sdk_angular/src/app/app.config.ts | 2 + .../src/app/app.routes.server.ts | 3 + .../src/app/components/control-panel/index.ts | 12 +- .../src/app/components/tracking-log/index.ts | 6 +- .../src/app/pages/page-two/index.ts | 2 +- .../src/app/services/contentful-client.ts | 16 +- .../web-sdk_angular/src/app/services/entry.ts | 41 ++++- .../src/app/services/live-updates.ts | 9 +- .../src/app/services/optimization-server.ts | 164 ++++++++++++++++++ .../src/app/services/optimization.ts | 35 +++- .../src/app/transfer-state-keys.ts | 37 ++++ .../web-sdk_angular/src/main.server.ts | 9 + implementations/web-sdk_angular/src/server.ts | 57 ++++++ lib/e2e-web/src/index.ts | 4 +- 18 files changed, 434 insertions(+), 32 deletions(-) create mode 100644 implementations/web-sdk_angular/src/app/app.config.server.ts create mode 100644 implementations/web-sdk_angular/src/app/app.routes.server.ts create mode 100644 implementations/web-sdk_angular/src/app/services/optimization-server.ts create mode 100644 implementations/web-sdk_angular/src/app/transfer-state-keys.ts create mode 100644 implementations/web-sdk_angular/src/main.server.ts create mode 100644 implementations/web-sdk_angular/src/server.ts diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index fe8ff133f..86f67c06c 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -15,6 +15,9 @@ "outputPath": "dist/web-sdk_angular", "index": "src/index.html", "browser": "src/main.ts", + "server": "src/main.server.ts", + "outputMode": "server", + "ssr": { "entry": "src/server.ts" }, "tsConfig": "tsconfig.json", "assets": [], "styles": ["./node_modules/e2e-web/src/theme.css"], diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index becc912da..b5744df81 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -8,6 +8,7 @@ "generate:env": "pnpm exec tsx scripts/generate-env.ts", "dev": "pnpm generate:env && ng serve", "build": "ng build", + "start": "node dist/web-sdk_angular/server/server.mjs", "clean": "rimraf ./dist", "serve": "pnpm serve:mocks && pnpm serve:app", "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", @@ -27,11 +28,16 @@ "@angular/compiler": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0", + "@angular/ssr": "^22.0.0", + "@contentful/optimization-node": "*", "@contentful/optimization-web": "*", "@contentful/optimization-web-preview-panel": "*", "@contentful/rich-text-types": "^17.2.7", "contentful": "^11.12.4", + "express": "5.2.1", + "express-rate-limit": "8.2.1", "rxjs": "~7.8.0", "tslib": "^2.8.1" }, @@ -40,6 +46,7 @@ "@angular/build": "^22.0.0", "@angular/cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0", + "@types/express": "5.0.6", "@types/node": "^24.0.13", "dotenv": "^17.4.2", "pm2": "^6.0.14", diff --git a/implementations/web-sdk_angular/pnpm-workspace.yaml b/implementations/web-sdk_angular/pnpm-workspace.yaml index 691b6b5e6..2e4f5b7c6 100644 --- a/implementations/web-sdk_angular/pnpm-workspace.yaml +++ b/implementations/web-sdk_angular/pnpm-workspace.yaml @@ -4,5 +4,6 @@ overrides: '@contentful/optimization-api-client': 'file:../../pkgs/contentful-optimization-api-client-0.0.0.tgz' '@contentful/optimization-api-schemas': 'file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz' '@contentful/optimization-core': 'file:../../pkgs/contentful-optimization-core-0.0.0.tgz' + '@contentful/optimization-node': 'file:../../pkgs/contentful-optimization-node-0.0.0.tgz' '@contentful/optimization-web': 'file:../../pkgs/contentful-optimization-web-0.0.0.tgz' '@contentful/optimization-web-preview-panel': 'file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz' diff --git a/implementations/web-sdk_angular/src/app/app.config.server.ts b/implementations/web-sdk_angular/src/app/app.config.server.ts new file mode 100644 index 000000000..43bee5680 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.config.server.ts @@ -0,0 +1,58 @@ +import { + inject, + mergeApplicationConfig, + provideAppInitializer, + REQUEST, + RESPONSE_INIT, + TransferState, + type ApplicationConfig, +} from '@angular/core' +import { provideServerRendering, withRoutes } from '@angular/ssr' +import { PAGES } from 'e2e-web' +import { appConfig } from './app.config' +import { serverRoutes } from './app.routes.server' +import { NG_CONTENTFUL_OPTIMIZATION_CONFIG } from './config' +import { NgContentfulClient } from './services/contentful-client' +import { + createServerOptimization, + getServerOptimizationData, + persistAnonymousIdCookie, + resolveServerEntries, + stampServerHandoff, +} from './services/optimization-server' + +async function runServerPreflight(): Promise { + const request = inject(REQUEST, { optional: true }) + if (!request) return + + const responseInit = inject(RESPONSE_INIT, { optional: true }) + const transferState = inject(TransferState) + const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) + const contentful = inject(NgContentfulClient) + + const sdk = await createServerOptimization(config) + const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] + const baselines = await contentful.fetchEntries(baselineIds) + + const serverData = await getServerOptimizationData(sdk, request, config.locale) + + if (responseInit && serverData.canPersistProfile && serverData.profileId !== undefined) { + persistAnonymousIdCookie(responseInit, serverData.profileId) + } + + const resolvedEntries = resolveServerEntries( + sdk, + baselines, + serverData.data?.selectedOptimizations, + ) + stampServerHandoff(transferState, serverData, resolvedEntries) +} + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(withRoutes(serverRoutes)), + provideAppInitializer(runServerPreflight), + ], +} + +export const config = mergeApplicationConfig(appConfig, serverConfig) diff --git a/implementations/web-sdk_angular/src/app/app.config.ts b/implementations/web-sdk_angular/src/app/app.config.ts index e5c1f4d96..dc24b44ba 100644 --- a/implementations/web-sdk_angular/src/app/app.config.ts +++ b/implementations/web-sdk_angular/src/app/app.config.ts @@ -3,6 +3,7 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core' +import { provideClientHydration, withEventReplay } from '@angular/platform-browser' import { provideRouter } from '@angular/router' import { routes } from './app.routes' import { provideContentfulOptimizationConfig, resolveLogLevel } from './config' @@ -12,6 +13,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), + provideClientHydration(withEventReplay()), provideRouter(routes), provideContentfulOptimizationConfig({ clientId: environment.PUBLIC_NINETAILED_CLIENT_ID, diff --git a/implementations/web-sdk_angular/src/app/app.routes.server.ts b/implementations/web-sdk_angular/src/app/app.routes.server.ts new file mode 100644 index 000000000..c76c2b2c1 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.routes.server.ts @@ -0,0 +1,3 @@ +import { RenderMode, type ServerRoute } from '@angular/ssr' + +export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }] diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index d0d9612ff..4379c9169 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -22,21 +22,21 @@ export class ControlPanel { ) // This is an active exposure stream. Core does not mark one-off flag reads as // tracked until a flag-view event is actually accepted. - protected readonly booleanFlag = fromSdkState( - this.optimization.sdk.states.flag('boolean'), + protected readonly booleanFlag = fromSdkState(() => + this.optimization.sdk?.states.flag('boolean'), ) protected toggleConsent(): void { - this.optimization.sdk.consent(this.consent() !== true) + this.optimization.sdk?.consent(this.consent() !== true) } protected identify(): void { - void this.optimization.sdk.identify({ userId: 'charles', traits: { identified: true } }) + void this.optimization.sdk?.identify({ userId: 'charles', traits: { identified: true } }) } protected reset(): void { - this.optimization.sdk.reset() - void this.optimization.sdk.page() + this.optimization.sdk?.reset() + void this.optimization.sdk?.page() } protected trackConversion(): void { diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index 659782542..4b50fcbdc 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -50,9 +50,13 @@ export class TrackingLog { }) constructor() { + const { optimization } = this + const { sdk } = optimization + if (!sdk) return + let pageSeq = 0 let componentSeq = 0 - const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => { + const sub = sdk.states.eventStream.subscribe((raw) => { if (raw != null) { this.rawEventsCount.update((n) => n + 1) } diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index a2702bd87..f4a9ff103 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -30,7 +30,7 @@ export class PageTwo { } protected readonly trackConversion = (): void => { - void this.optimization.sdk.trackView({ + void this.optimization.sdk?.trackView({ componentId: PAGE_TWO_COMPONENT_ID, viewId: crypto.randomUUID(), viewDurationMs: 0, diff --git a/implementations/web-sdk_angular/src/app/services/contentful-client.ts b/implementations/web-sdk_angular/src/app/services/contentful-client.ts index b7c68cd63..3f873957e 100644 --- a/implementations/web-sdk_angular/src/app/services/contentful-client.ts +++ b/implementations/web-sdk_angular/src/app/services/contentful-client.ts @@ -1,6 +1,7 @@ -import { inject, Injectable, resource, type ResourceRef } from '@angular/core' +import { inject, Injectable, resource, TransferState, type ResourceRef } from '@angular/core' import type { ContentfulClientApi, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import { getOrCreateBaseClient, NG_CONTENTFUL_OPTIMIZATION_CONFIG } from '../config' +import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys' export interface ContentEntryFields { text?: EntryFieldTypes.Text | EntryFieldTypes.RichText @@ -15,7 +16,8 @@ const INCLUDE_DEPTH = 10 @Injectable({ providedIn: 'root' }) export class NgContentfulClient { private readonly client: ContentfulClientApi - private readonly locale: string + private readonly transferState = inject(TransferState) + readonly locale: string constructor() { const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) @@ -41,8 +43,14 @@ export class NgContentfulClient { loadEntries = (ids: readonly string[]): ResourceRef | undefined> => resource({ loader: async (): Promise> => { - const list = await this.fetchEntries(ids) - return new Map(list.map((e) => [e.sys.id, e])) + const handoff = this.transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined) + if (handoff && ids.every((id) => Object.hasOwn(handoff, id))) { + // Hydration path: server already fetched these baselines and stamped + // them into TransferState. Skip the duplicate CDA roundtrip. + return new Map(ids.map((id) => [id, handoff[id].baseline as Entry])) + } + const list = await this.fetchEntries(ids) + return new Map(list.map((entry) => [entry.sys.id, entry])) }, }) } diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 613119cee..408e3588a 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -6,6 +6,7 @@ import { ElementRef, inject, signal, + TransferState, untracked, type Signal, } from '@angular/core' @@ -14,6 +15,7 @@ import { isMergeTagEntry, type MergeTagEntry } from '@contentful/optimization-we import type { Document, Text } from '@contentful/rich-text-types' import { INLINES } from '@contentful/rich-text-types' import type { Entry } from 'contentful' +import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys' import { isRecord } from '../utils' import { NgContentfulOptimization } from './optimization' @@ -78,14 +80,18 @@ function setupManualTracking(result: Signal, manualTracking: Sign }) function track(): void { + const { sdk } = optimization + if (!sdk) return const { entryId, optimizationId, sticky, variantIndex } = result() - optimization.sdk.tracking.enableElement('views', elementRef.nativeElement, { + sdk.tracking.enableElement('views', elementRef.nativeElement, { data: { entryId, optimizationId, sticky, variantIndex }, }) } function clear(): void { - optimization.sdk.tracking.clearElement('views', elementRef.nativeElement) + const { sdk } = optimization + if (!sdk) return + sdk.tracking.clearElement('views', elementRef.nativeElement) } effect(() => { @@ -108,6 +114,7 @@ export function injectContentfulEntry({ manualTracking?: Signal }): Signal { const optimization = inject(NgContentfulOptimization) + const transferState = inject(TransferState) function liveRead(sig: Signal): T { if (isLive()) return sig() @@ -120,21 +127,41 @@ export function injectContentfulEntry({ const variant = computed(() => { const raw = entry() + const { sdk } = optimization + if (sdk) { + return { + raw, + resolved: sdk.resolveOptimizedEntry(raw, liveRead(optimization.selectedOptimizations)), + } + } + // Server render: lift the server-resolved entry from TransferState if present + // so the initial HTML reflects the personalized variant. Falls back to the + // baseline when no handoff exists (e.g. consent denied — server skipped resolve). + const handoff = transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined) + const slot = handoff?.[raw.sys.id] return { raw, - resolved: optimization.sdk.resolveOptimizedEntry( - raw, - liveRead(optimization.selectedOptimizations), - ), + resolved: { + entry: slot?.resolvedEntry ?? raw, + selectedOptimization: + slot?.optimizationId !== undefined + ? { + experienceId: slot.optimizationId, + variantIndex: slot.variantIndex ?? 0, + sticky: slot.sticky, + } + : undefined, + }, } }) const result = computed(() => { const { raw, resolved } = variant() const profile = liveRead(optimization.profile) + const { sdk } = optimization let mergeTagResolved: boolean | undefined = undefined const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = profile ? optimization.sdk.getMergeTagValue(target, profile) : undefined + const value = sdk && profile ? sdk.getMergeTagValue(target, profile) : undefined if (value !== undefined) mergeTagResolved = true else mergeTagResolved ??= false return value ?? target.fields.nt_fallback diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index b8dc44180..cbc23b58c 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -3,6 +3,7 @@ import { fromSdkState } from '../utils' import { NgContentfulOptimization } from './optimization' function clickPreviewPanelToggle(): void { + if (typeof document === 'undefined') return const panel = document.querySelector('ctfl-opt-preview-panel') const btn = panel?.shadowRoot?.querySelector('button.toggle-drawer') btn?.click() @@ -10,13 +11,15 @@ function clickPreviewPanelToggle(): void { @Injectable({ providedIn: 'root' }) export class NgLiveUpdates { - private readonly sdk = inject(NgContentfulOptimization).sdk + private readonly optimization = inject(NgContentfulOptimization) private readonly globalLiveUpdatesSignal = signal(false) private readonly previewPanelAttached = fromSdkState( - this.sdk.states.previewPanelAttached, + () => this.optimization.sdk?.states.previewPanelAttached, + ) + private readonly previewPanelOpen = fromSdkState( + () => this.optimization.sdk?.states.previewPanelOpen, ) - private readonly previewPanelOpen = fromSdkState(this.sdk.states.previewPanelOpen) readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly() readonly previewPanelVisible = computed( diff --git a/implementations/web-sdk_angular/src/app/services/optimization-server.ts b/implementations/web-sdk_angular/src/app/services/optimization-server.ts new file mode 100644 index 000000000..989809c38 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/services/optimization-server.ts @@ -0,0 +1,164 @@ +import type { TransferState } from '@angular/core' +import type ContentfulOptimization from '@contentful/optimization-node' +import type { OptimizationData } from '@contentful/optimization-node/api-schemas' +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' +import type { CoreStatelessRequest } from '@contentful/optimization-node/core-sdk' +import type { Entry } from 'contentful' +import type { NgContentfulOptimizationConfig } from '../config' +import { resolveLogLevel } from '../config' +import { + SERVER_OPTIMIZATION_KEY, + SERVER_RESOLVED_ENTRIES_KEY, + type ResolvedEntryHandoff, + type ServerHandoff, +} from '../transfer-state-keys' + +const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' + +/** + * Reads a cookie value out of a Web `Request`. Mirrors the convention the + * `nextjs-sdk_ssr` reference uses for the `app-personalization-consent` and + * anonymous-id cookies. + */ +function readCookie(request: Request, name: string): string | undefined { + const header = request.headers.get('cookie') ?? '' + for (const part of header.split(';')) { + const trimmed = part.trim() + if (!trimmed) continue + const eq = trimmed.indexOf('=') + if (eq < 0) continue + if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1) + } + return undefined +} + +/** + * Construct the Node SDK instance. Dynamic-imported by the caller so the Node + * SDK and its Node-only dependencies stay out of the browser bundle. + */ +export async function createServerOptimization( + config: NgContentfulOptimizationConfig, +): Promise { + const { default: NodeContentfulOptimization } = await import('@contentful/optimization-node') + return new NodeContentfulOptimization({ + clientId: config.clientId, + environment: config.environment, + logLevel: resolveLogLevel(config.logLevel), + locale: config.locale, + app: config.app, + api: { + insightsBaseUrl: config.insightsBaseUrl, + experienceBaseUrl: config.experienceBaseUrl, + }, + }) +} + +export interface ServerOptimizationData { + readonly data: OptimizationData | undefined + readonly requestOptimization: CoreStatelessRequest | undefined + readonly consentGranted: boolean + readonly anonymousId: string | undefined + readonly profileId: string | undefined + readonly canPersistProfile: boolean +} + +/** + * Run the SDK preflight for a single SSR request: bind the Node SDK to the + * inbound request's consent + anonymous-id cookies and emit the initial page + * event. Returns the resulting OptimizationData (selected optimizations, + * profile) for downstream resolution and TransferState handoff. + */ +export async function getServerOptimizationData( + sdk: ContentfulOptimization, + request: Request, + locale: string, +): Promise { + const consentGranted = readCookie(request, APP_PERSONALIZATION_CONSENT_COOKIE) === 'granted' + const anonymousId = readCookie(request, ANONYMOUS_ID_COOKIE) + + if (!consentGranted) { + return { + data: undefined, + requestOptimization: undefined, + consentGranted, + anonymousId, + profileId: anonymousId, + canPersistProfile: false, + } + } + + const requestOptimization = sdk.forRequest({ + consent: { events: true, persistence: true }, + locale, + ...(anonymousId ? { profile: { id: anonymousId } } : {}), + }) + const data = await requestOptimization.page() + + return { + data, + requestOptimization, + consentGranted, + anonymousId, + profileId: data?.profile.id ?? requestOptimization.profile?.id, + canPersistProfile: requestOptimization.canPersistProfile, + } +} + +/** + * Resolve baseline entries against the server-side `selectedOptimizations` + * snapshot and return a TransferState-ready map keyed by baseline entry id. + */ +export function resolveServerEntries( + sdk: ContentfulOptimization, + baselines: readonly Entry[], + selectedOptimizations: OptimizationData['selectedOptimizations'] | undefined, +): Record { + const resolved: Record = {} + for (const baseline of baselines) { + const result = sdk.resolveOptimizedEntry(baseline, selectedOptimizations) + resolved[baseline.sys.id] = { + baseline, + resolvedEntry: result.entry, + optimizationId: result.selectedOptimization?.experienceId, + variantIndex: result.selectedOptimization?.variantIndex, + sticky: result.selectedOptimization?.sticky, + } + } + return resolved +} + +/** + * Persist the SDK anonymous-id back to the client via Set-Cookie. Browser + * pages that hydrate after this read the cookie via the Web SDK on first + * `identify()`/`page()` so the same profile is observed across runtimes. + */ +export function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { + const headers = + responseInit.headers instanceof Headers + ? responseInit.headers + : new Headers(responseInit.headers) + headers.append('set-cookie', `${ANONYMOUS_ID_COOKIE}=${profileId}; Path=/; SameSite=Lax`) + responseInit.headers = headers +} + +/** + * Stamp the server preflight + per-entry resolution into TransferState so the + * browser hydration step can avoid duplicate fetches and re-resolutions. + */ +export function stampServerHandoff( + transferState: TransferState, + serverData: ServerOptimizationData, + resolvedEntries: Record, +): void { + const handoff: ServerHandoff = { + consent: serverData.consentGranted, + profileId: serverData.profileId, + profile: serverData.data?.profile, + selectedOptimizations: serverData.data?.selectedOptimizations, + } + transferState.set(SERVER_OPTIMIZATION_KEY, handoff) + transferState.set>( + SERVER_RESOLVED_ENTRIES_KEY, + resolvedEntries, + ) +} diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index 912f224f0..a2e853f53 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -1,4 +1,5 @@ -import { inject, Injectable, type OnDestroy, type Signal } from '@angular/core' +import { isPlatformBrowser } from '@angular/common' +import { inject, Injectable, PLATFORM_ID, signal, type OnDestroy, type Signal } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import ContentfulOptimization from '@contentful/optimization-web' import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' @@ -59,18 +60,36 @@ function getOrCreateInstance( return instance } +/** + * Browser-only SDK service. The Web SDK constructor touches `localStorage` at + * construction time, so on the server we leave `sdk` as `undefined` and skip + * SDK side effects. Components dereferencing `sdk?.` are no-ops during SSR; + * the same components run normally after hydration once the browser SDK is + * constructed here. + */ @Injectable({ providedIn: 'root' }) export class NgContentfulOptimization implements OnDestroy { - readonly sdk: NgContentfulOptimizationInstance + readonly sdk: NgContentfulOptimizationInstance | undefined readonly consent: Signal readonly profile: Signal readonly selectedOptimizations: Signal - private readonly routerSubscription: Subscription + private readonly routerSubscription: Subscription | undefined constructor() { const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) const router = inject(Router) + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) + + if (!isBrowser) { + this.sdk = undefined + this.consent = signal(undefined).asReadonly() + this.profile = signal(undefined).asReadonly() + this.selectedOptimizations = signal( + undefined, + ).asReadonly() + return + } this.sdk = getOrCreateInstance(config) @@ -79,9 +98,7 @@ export class NgContentfulOptimization implements OnDestroy { } this.consent = fromSdkState(this.sdk.states.consent) - this.profile = fromSdkState(this.sdk.states.profile) - this.selectedOptimizations = fromSdkState(this.sdk.states.selectedOptimizations) // Page events must fire on every route change including the initial load. @@ -89,13 +106,15 @@ export class NgContentfulOptimization implements OnDestroy { this.routerSubscription = router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => { - void this.sdk.page({ properties: { url: window.location.origin + e.urlAfterRedirects } }) + void this.sdk?.page({ + properties: { url: window.location.origin + e.urlAfterRedirects }, + }) }) } ngOnDestroy(): void { - this.routerSubscription.unsubscribe() - this.sdk.destroy() + this.routerSubscription?.unsubscribe() + this.sdk?.destroy() instance = undefined attachmentStarted = false } diff --git a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts new file mode 100644 index 000000000..45fcaa9b0 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts @@ -0,0 +1,37 @@ +import { makeStateKey, type StateKey } from '@angular/core' +import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import type { Entry } from 'contentful' + +/** + * Snapshot of the personalization context resolved server-side. Stamped into + * `TransferState` during SSR and read by browser code on hydration. + */ +export interface ServerHandoff { + /** Whether the request had `app-personalization-consent=granted`. */ + readonly consent: boolean + /** Anonymous profile id observed in the request cookie (if any). */ + readonly profileId: string | undefined + /** Profile snapshot returned by the server-side `page()` call. */ + readonly profile: Profile | undefined + /** Selected optimizations the server applied when resolving baseline entries. */ + readonly selectedOptimizations: SelectedOptimizationArray | undefined +} + +/** + * Per-baseline-entry result of `sdk.resolveOptimizedEntry()` carried across + * the hydration boundary. The browser uses these to skip a duplicate Experience + * API roundtrip on initial render. + */ +export interface ResolvedEntryHandoff { + readonly baseline: Entry + readonly resolvedEntry: Entry + readonly optimizationId: string | undefined + readonly variantIndex: number | undefined + readonly sticky: boolean | undefined +} + +export const SERVER_OPTIMIZATION_KEY: StateKey = + makeStateKey('ssr-optimization') + +export const SERVER_RESOLVED_ENTRIES_KEY: StateKey> = + makeStateKey>('ssr-resolved-entries') diff --git a/implementations/web-sdk_angular/src/main.server.ts b/implementations/web-sdk_angular/src/main.server.ts new file mode 100644 index 000000000..bbc88a9b5 --- /dev/null +++ b/implementations/web-sdk_angular/src/main.server.ts @@ -0,0 +1,9 @@ +import { bootstrapApplication, type BootstrapContext } from '@angular/platform-browser' +import { App } from './app/app' +import { config } from './app/app.config.server' + +async function bootstrap(context: BootstrapContext): Promise { + return await bootstrapApplication(App, config, context) +} + +export default bootstrap diff --git a/implementations/web-sdk_angular/src/server.ts b/implementations/web-sdk_angular/src/server.ts new file mode 100644 index 000000000..4a8383392 --- /dev/null +++ b/implementations/web-sdk_angular/src/server.ts @@ -0,0 +1,57 @@ +/* eslint-disable no-console */ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node' +import express, { type Express } from 'express' +import rateLimit from 'express-rate-limit' +import { join } from 'node:path' + +const DEFAULT_PORT = 4200 +const RATE_LIMIT_WINDOW_MS = 30_000 +const RATE_LIMIT_MAX = 1000 + +const limiter = rateLimit({ + windowMs: RATE_LIMIT_WINDOW_MS, + max: RATE_LIMIT_MAX, +}) + +const browserDistFolder = join(import.meta.dirname, '../browser') +const app: Express = express() +app.use(limiter) + +const angularApp = new AngularNodeAppEngine() + +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }), +) + +app.use(async (req, res, next) => { + try { + const response = await angularApp.handle(req) + if (response) { + await writeResponseToNodeResponse(response, res) + } else { + next() + } + } catch (error) { + next(error) + } +}) + +if (isMainModule(import.meta.url) || process.env.pm_id) { + const port = process.env.PORT ?? DEFAULT_PORT + app.listen(port, (error) => { + if (error) throw error + console.log(`Express is listening at http://localhost:${port}`) + }) +} + +export const reqHandler = createNodeRequestHandler(app) +export default app diff --git a/lib/e2e-web/src/index.ts b/lib/e2e-web/src/index.ts index a4e8934cf..48ae12957 100644 --- a/lib/e2e-web/src/index.ts +++ b/lib/e2e-web/src/index.ts @@ -1,2 +1,2 @@ -export { CLICK_SCENARIOS, CLICK_SCENARIO_IDS, PAGES } from './fixtures' -export type { EntryClickScenario } from './fixtures' +export { CLICK_SCENARIOS, CLICK_SCENARIO_IDS, PAGES } from './fixtures.js' +export type { EntryClickScenario } from './fixtures.js' From cf5a53f50e483f1297a1e0fe8b760a0fa45fe0f4 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Wed, 24 Jun 2026 12:03:49 +0200 Subject: [PATCH 2/4] chore: addressing wiz code review --- implementations/web-sdk_angular/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index b5744df81..58a98a194 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -8,7 +8,6 @@ "generate:env": "pnpm exec tsx scripts/generate-env.ts", "dev": "pnpm generate:env && ng serve", "build": "ng build", - "start": "node dist/web-sdk_angular/server/server.mjs", "clean": "rimraf ./dist", "serve": "pnpm serve:mocks && pnpm serve:app", "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", @@ -37,7 +36,7 @@ "@contentful/rich-text-types": "^17.2.7", "contentful": "^11.12.4", "express": "5.2.1", - "express-rate-limit": "8.2.1", + "express-rate-limit": "8.2.2", "rxjs": "~7.8.0", "tslib": "^2.8.1" }, From aa1b29016c0018409803c5206dd91386b2c7a598 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Wed, 24 Jun 2026 12:05:03 +0200 Subject: [PATCH 3/4] chore: make SSR see consent state, render Utilities panel from TransferState --- .../src/app/app.config.server.ts | 4 +- .../src/app/components/control-panel/index.ts | 23 +++++- .../web-sdk_angular/src/app/services/entry.ts | 9 +-- .../src/app/services/optimization-server.ts | 74 +++++++++---------- .../src/app/services/optimization.ts | 25 ++++++- .../src/app/transfer-state-keys.ts | 30 ++++---- implementations/web-sdk_angular/src/server.ts | 8 +- 7 files changed, 98 insertions(+), 75 deletions(-) diff --git a/implementations/web-sdk_angular/src/app/app.config.server.ts b/implementations/web-sdk_angular/src/app/app.config.server.ts index 43bee5680..e67460013 100644 --- a/implementations/web-sdk_angular/src/app/app.config.server.ts +++ b/implementations/web-sdk_angular/src/app/app.config.server.ts @@ -36,14 +36,14 @@ async function runServerPreflight(): Promise { const serverData = await getServerOptimizationData(sdk, request, config.locale) - if (responseInit && serverData.canPersistProfile && serverData.profileId !== undefined) { + if (serverData.consentGranted && serverData.canPersistProfile && responseInit) { persistAnonymousIdCookie(responseInit, serverData.profileId) } const resolvedEntries = resolveServerEntries( sdk, baselines, - serverData.data?.selectedOptimizations, + serverData.consentGranted ? serverData.data.selectedOptimizations : [], ) stampServerHandoff(transferState, serverData, resolvedEntries) } diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index 4379c9169..645fca510 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -1,8 +1,22 @@ -import { Component, computed, inject, input } from '@angular/core' +import { Component, computed, effect, inject, input } from '@angular/core' import { NgLiveUpdates } from '../../services/live-updates' import { NgContentfulOptimization } from '../../services/optimization' import { fromSdkState } from '../../utils' +const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' + +/** + * Mirror the SDK's internal consent state into a cookie the SSR server can + * read on the next request. Without this, the server can't tell whether the + * user has granted consent and falls back to baseline rendering. Matches the + * pattern used by `nextjs-sdk_ssr/components/InteractiveControls.tsx`. + */ +function syncConsentCookie(consent: boolean): void { + if (typeof document === 'undefined') return + const value = consent ? 'granted' : 'denied' + document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax` +} + @Component({ selector: 'app-control-panel', templateUrl: './index.html', @@ -26,6 +40,13 @@ export class ControlPanel { this.optimization.sdk?.states.flag('boolean'), ) + constructor() { + effect(() => { + const value = this.consent() + if (typeof value === 'boolean') syncConsentCookie(value) + }) + } + protected toggleConsent(): void { this.optimization.sdk?.consent(this.consent() !== true) } diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 408e3588a..3bf691ae3 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -143,14 +143,7 @@ export function injectContentfulEntry({ raw, resolved: { entry: slot?.resolvedEntry ?? raw, - selectedOptimization: - slot?.optimizationId !== undefined - ? { - experienceId: slot.optimizationId, - variantIndex: slot.variantIndex ?? 0, - sticky: slot.sticky, - } - : undefined, + selectedOptimization: slot?.selectedOptimization ?? undefined, }, } }) diff --git a/implementations/web-sdk_angular/src/app/services/optimization-server.ts b/implementations/web-sdk_angular/src/app/services/optimization-server.ts index 989809c38..a9a850299 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization-server.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization-server.ts @@ -2,7 +2,6 @@ import type { TransferState } from '@angular/core' import type ContentfulOptimization from '@contentful/optimization-node' import type { OptimizationData } from '@contentful/optimization-node/api-schemas' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' -import type { CoreStatelessRequest } from '@contentful/optimization-node/core-sdk' import type { Entry } from 'contentful' import type { NgContentfulOptimizationConfig } from '../config' import { resolveLogLevel } from '../config' @@ -18,9 +17,10 @@ const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' /** * Reads a cookie value out of a Web `Request`. Mirrors the convention the * `nextjs-sdk_ssr` reference uses for the `app-personalization-consent` and - * anonymous-id cookies. + * anonymous-id cookies. Returns `null` when the cookie is absent so callers + * can branch on a single contract instead of an `undefined` union. */ -function readCookie(request: Request, name: string): string | undefined { +function readCookie(request: Request, name: string): string | null { const header = request.headers.get('cookie') ?? '' for (const part of header.split(';')) { const trimmed = part.trim() @@ -29,7 +29,7 @@ function readCookie(request: Request, name: string): string | undefined { if (eq < 0) continue if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1) } - return undefined + return null } /** @@ -53,14 +53,21 @@ export async function createServerOptimization( }) } -export interface ServerOptimizationData { - readonly data: OptimizationData | undefined - readonly requestOptimization: CoreStatelessRequest | undefined - readonly consentGranted: boolean - readonly anonymousId: string | undefined - readonly profileId: string | undefined - readonly canPersistProfile: boolean -} +/** + * Outcome of the server-side preflight for one SSR request. Discriminated on + * `consentGranted` so callers either get the full personalization context or + * a "no SDK work happened" branch — never a half-populated value. + */ +export type ServerOptimizationData = + | { + readonly consentGranted: false + } + | { + readonly consentGranted: true + readonly data: OptimizationData + readonly profileId: string + readonly canPersistProfile: boolean + } /** * Run the SDK preflight for a single SSR request: bind the Node SDK to the @@ -74,32 +81,21 @@ export async function getServerOptimizationData( locale: string, ): Promise { const consentGranted = readCookie(request, APP_PERSONALIZATION_CONSENT_COOKIE) === 'granted' - const anonymousId = readCookie(request, ANONYMOUS_ID_COOKIE) - - if (!consentGranted) { - return { - data: undefined, - requestOptimization: undefined, - consentGranted, - anonymousId, - profileId: anonymousId, - canPersistProfile: false, - } - } + if (!consentGranted) return { consentGranted: false } + const anonymousId = readCookie(request, ANONYMOUS_ID_COOKIE) const requestOptimization = sdk.forRequest({ consent: { events: true, persistence: true }, locale, - ...(anonymousId ? { profile: { id: anonymousId } } : {}), + ...(anonymousId === null ? {} : { profile: { id: anonymousId } }), }) const data = await requestOptimization.page() + if (!data) return { consentGranted: false } return { + consentGranted: true, data, - requestOptimization, - consentGranted, - anonymousId, - profileId: data?.profile.id ?? requestOptimization.profile?.id, + profileId: data.profile.id, canPersistProfile: requestOptimization.canPersistProfile, } } @@ -111,7 +107,7 @@ export async function getServerOptimizationData( export function resolveServerEntries( sdk: ContentfulOptimization, baselines: readonly Entry[], - selectedOptimizations: OptimizationData['selectedOptimizations'] | undefined, + selectedOptimizations: OptimizationData['selectedOptimizations'], ): Record { const resolved: Record = {} for (const baseline of baselines) { @@ -119,9 +115,7 @@ export function resolveServerEntries( resolved[baseline.sys.id] = { baseline, resolvedEntry: result.entry, - optimizationId: result.selectedOptimization?.experienceId, - variantIndex: result.selectedOptimization?.variantIndex, - sticky: result.selectedOptimization?.sticky, + selectedOptimization: result.selectedOptimization ?? null, } } return resolved @@ -150,12 +144,14 @@ export function stampServerHandoff( serverData: ServerOptimizationData, resolvedEntries: Record, ): void { - const handoff: ServerHandoff = { - consent: serverData.consentGranted, - profileId: serverData.profileId, - profile: serverData.data?.profile, - selectedOptimizations: serverData.data?.selectedOptimizations, - } + const handoff: ServerHandoff = serverData.consentGranted + ? { + consent: true, + profile: serverData.data.profile, + profileId: serverData.profileId, + selectedOptimizations: serverData.data.selectedOptimizations, + } + : { consent: false } transferState.set(SERVER_OPTIMIZATION_KEY, handoff) transferState.set>( SERVER_RESOLVED_ENTRIES_KEY, diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index a2e853f53..78ece40d1 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -1,5 +1,13 @@ import { isPlatformBrowser } from '@angular/common' -import { inject, Injectable, PLATFORM_ID, signal, type OnDestroy, type Signal } from '@angular/core' +import { + inject, + Injectable, + PLATFORM_ID, + signal, + TransferState, + type OnDestroy, + type Signal, +} from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import ContentfulOptimization from '@contentful/optimization-web' import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' @@ -11,6 +19,7 @@ import { NG_CONTENTFUL_OPTIMIZATION_CONFIG, resolveLogLevel, } from '../config' +import { SERVER_OPTIMIZATION_KEY } from '../transfer-state-keys' import { fromSdkState } from '../utils' export type NgContentfulOptimizationInstance = ContentfulOptimization @@ -82,11 +91,19 @@ export class NgContentfulOptimization implements OnDestroy { const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) if (!isBrowser) { + // On the server, seed the read-only signals from the SSR handoff so + // server-rendered templates reflect the same consent/profile state the + // server preflight observed. Without this, JS-disabled clients would + // see "undefined" / "0 active optimizations" in the Utilities panel + // even though the entry markup is fully personalized. + const handoff = inject(TransferState).get(SERVER_OPTIMIZATION_KEY, undefined) this.sdk = undefined - this.consent = signal(undefined).asReadonly() - this.profile = signal(undefined).asReadonly() + this.consent = signal(handoff?.consent === true).asReadonly() + this.profile = signal( + handoff?.consent === true ? handoff.profile : undefined, + ).asReadonly() this.selectedOptimizations = signal( - undefined, + handoff?.consent === true ? handoff.selectedOptimizations : undefined, ).asReadonly() return } diff --git a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts index 45fcaa9b0..f164a6205 100644 --- a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts +++ b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts @@ -1,21 +1,25 @@ import { makeStateKey, type StateKey } from '@angular/core' -import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import type { + Profile, + SelectedOptimization, + SelectedOptimizationArray, +} from '@contentful/optimization-web/api-schemas' import type { Entry } from 'contentful' /** * Snapshot of the personalization context resolved server-side. Stamped into * `TransferState` during SSR and read by browser code on hydration. */ -export interface ServerHandoff { - /** Whether the request had `app-personalization-consent=granted`. */ - readonly consent: boolean - /** Anonymous profile id observed in the request cookie (if any). */ - readonly profileId: string | undefined - /** Profile snapshot returned by the server-side `page()` call. */ - readonly profile: Profile | undefined - /** Selected optimizations the server applied when resolving baseline entries. */ - readonly selectedOptimizations: SelectedOptimizationArray | undefined -} +export type ServerHandoff = + | { + readonly consent: false + } + | { + readonly consent: true + readonly profile: Profile + readonly profileId: string + readonly selectedOptimizations: SelectedOptimizationArray + } /** * Per-baseline-entry result of `sdk.resolveOptimizedEntry()` carried across @@ -25,9 +29,7 @@ export interface ServerHandoff { export interface ResolvedEntryHandoff { readonly baseline: Entry readonly resolvedEntry: Entry - readonly optimizationId: string | undefined - readonly variantIndex: number | undefined - readonly sticky: boolean | undefined + readonly selectedOptimization: SelectedOptimization | null } export const SERVER_OPTIMIZATION_KEY: StateKey = diff --git a/implementations/web-sdk_angular/src/server.ts b/implementations/web-sdk_angular/src/server.ts index 4a8383392..8d7f48c77 100644 --- a/implementations/web-sdk_angular/src/server.ts +++ b/implementations/web-sdk_angular/src/server.ts @@ -1,10 +1,5 @@ /* eslint-disable no-console */ -import { - AngularNodeAppEngine, - createNodeRequestHandler, - isMainModule, - writeResponseToNodeResponse, -} from '@angular/ssr/node' +import { AngularNodeAppEngine, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node' import express, { type Express } from 'express' import rateLimit from 'express-rate-limit' import { join } from 'node:path' @@ -53,5 +48,4 @@ if (isMainModule(import.meta.url) || process.env.pm_id) { }) } -export const reqHandler = createNodeRequestHandler(app) export default app From 83b17b8fa2a49a0b2412477e2d44628835dd882e Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Wed, 24 Jun 2026 13:59:13 +0200 Subject: [PATCH 4/4] chore: unify SSR helpers into optimization.ts and remove nullable types --- .../src/app/app.config.server.ts | 50 +-- .../src/app/components/control-panel/index.ts | 16 +- .../src/app/components/tracking-log/index.ts | 5 +- .../src/app/pages/page-two/index.ts | 10 +- .../web-sdk_angular/src/app/services/entry.ts | 40 ++- .../src/app/services/live-updates.ts | 8 +- .../src/app/services/optimization-server.ts | 160 ---------- .../src/app/services/optimization.ts | 295 +++++++++++++++--- .../src/app/transfer-state-keys.ts | 21 +- 9 files changed, 319 insertions(+), 286 deletions(-) delete mode 100644 implementations/web-sdk_angular/src/app/services/optimization-server.ts diff --git a/implementations/web-sdk_angular/src/app/app.config.server.ts b/implementations/web-sdk_angular/src/app/app.config.server.ts index e67460013..c0f51d2ea 100644 --- a/implementations/web-sdk_angular/src/app/app.config.server.ts +++ b/implementations/web-sdk_angular/src/app/app.config.server.ts @@ -1,57 +1,13 @@ -import { - inject, - mergeApplicationConfig, - provideAppInitializer, - REQUEST, - RESPONSE_INIT, - TransferState, - type ApplicationConfig, -} from '@angular/core' +import { mergeApplicationConfig, type ApplicationConfig } from '@angular/core' import { provideServerRendering, withRoutes } from '@angular/ssr' -import { PAGES } from 'e2e-web' import { appConfig } from './app.config' import { serverRoutes } from './app.routes.server' -import { NG_CONTENTFUL_OPTIMIZATION_CONFIG } from './config' -import { NgContentfulClient } from './services/contentful-client' -import { - createServerOptimization, - getServerOptimizationData, - persistAnonymousIdCookie, - resolveServerEntries, - stampServerHandoff, -} from './services/optimization-server' - -async function runServerPreflight(): Promise { - const request = inject(REQUEST, { optional: true }) - if (!request) return - - const responseInit = inject(RESPONSE_INIT, { optional: true }) - const transferState = inject(TransferState) - const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) - const contentful = inject(NgContentfulClient) - - const sdk = await createServerOptimization(config) - const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] - const baselines = await contentful.fetchEntries(baselineIds) - - const serverData = await getServerOptimizationData(sdk, request, config.locale) - - if (serverData.consentGranted && serverData.canPersistProfile && responseInit) { - persistAnonymousIdCookie(responseInit, serverData.profileId) - } - - const resolvedEntries = resolveServerEntries( - sdk, - baselines, - serverData.consentGranted ? serverData.data.selectedOptimizations : [], - ) - stampServerHandoff(transferState, serverData, resolvedEntries) -} +import { provideServerOptimizationInitializer } from './services/optimization' const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), - provideAppInitializer(runServerPreflight), + provideServerOptimizationInitializer(), ], } diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index 645fca510..144c262d6 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -37,7 +37,7 @@ export class ControlPanel { // This is an active exposure stream. Core does not mark one-off flag reads as // tracked until a flag-view event is actually accepted. protected readonly booleanFlag = fromSdkState(() => - this.optimization.sdk?.states.flag('boolean'), + this.optimization.withSdk((sdk) => sdk.states.flag('boolean')), ) constructor() { @@ -48,16 +48,22 @@ export class ControlPanel { } protected toggleConsent(): void { - this.optimization.sdk?.consent(this.consent() !== true) + this.optimization.withSdk((sdk) => { + sdk.consent(this.consent() !== true) + }) } protected identify(): void { - void this.optimization.sdk?.identify({ userId: 'charles', traits: { identified: true } }) + this.optimization.withSdk((sdk) => { + void sdk.identify({ userId: 'charles', traits: { identified: true } }) + }) } protected reset(): void { - this.optimization.sdk?.reset() - void this.optimization.sdk?.page() + this.optimization.withSdk((sdk) => { + sdk.reset() + void sdk.page() + }) } protected trackConversion(): void { diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index 4b50fcbdc..2983ad439 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -51,8 +51,9 @@ export class TrackingLog { constructor() { const { optimization } = this - const { sdk } = optimization - if (!sdk) return + const { context } = optimization + if (context.platform !== 'browser') return + const { sdk } = context let pageSeq = 0 let componentSeq = 0 diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index f4a9ff103..71a8495e2 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -30,10 +30,12 @@ export class PageTwo { } protected readonly trackConversion = (): void => { - void this.optimization.sdk?.trackView({ - componentId: PAGE_TWO_COMPONENT_ID, - viewId: crypto.randomUUID(), - viewDurationMs: 0, + this.optimization.withSdk((sdk) => { + void sdk.trackView({ + componentId: PAGE_TWO_COMPONENT_ID, + viewId: crypto.randomUUID(), + viewDurationMs: 0, + }) }) } } diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 3bf691ae3..f5ba96a1d 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -80,18 +80,18 @@ function setupManualTracking(result: Signal, manualTracking: Sign }) function track(): void { - const { sdk } = optimization - if (!sdk) return - const { entryId, optimizationId, sticky, variantIndex } = result() - sdk.tracking.enableElement('views', elementRef.nativeElement, { - data: { entryId, optimizationId, sticky, variantIndex }, + optimization.withSdk((sdk) => { + const { entryId, optimizationId, sticky, variantIndex } = result() + sdk.tracking.enableElement('views', elementRef.nativeElement, { + data: { entryId, optimizationId, sticky, variantIndex }, + }) }) } function clear(): void { - const { sdk } = optimization - if (!sdk) return - sdk.tracking.clearElement('views', elementRef.nativeElement) + optimization.withSdk((sdk) => { + sdk.tracking.clearElement('views', elementRef.nativeElement) + }) } effect(() => { @@ -127,11 +127,13 @@ export function injectContentfulEntry({ const variant = computed(() => { const raw = entry() - const { sdk } = optimization - if (sdk) { + if (optimization.context.platform === 'browser') { return { raw, - resolved: sdk.resolveOptimizedEntry(raw, liveRead(optimization.selectedOptimizations)), + resolved: optimization.context.sdk.resolveOptimizedEntry( + raw, + liveRead(optimization.selectedOptimizations), + ), } } // Server render: lift the server-resolved entry from TransferState if present @@ -139,22 +141,26 @@ export function injectContentfulEntry({ // baseline when no handoff exists (e.g. consent denied — server skipped resolve). const handoff = transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined) const slot = handoff?.[raw.sys.id] + if (slot?.isVariant) { + return { + raw, + resolved: { entry: slot.resolvedEntry, selectedOptimization: slot.selectedOptimization }, + } + } return { raw, - resolved: { - entry: slot?.resolvedEntry ?? raw, - selectedOptimization: slot?.selectedOptimization ?? undefined, - }, + resolved: { entry: slot?.resolvedEntry ?? raw, selectedOptimization: undefined }, } }) const result = computed(() => { const { raw, resolved } = variant() const profile = liveRead(optimization.profile) - const { sdk } = optimization let mergeTagResolved: boolean | undefined = undefined const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = sdk && profile ? sdk.getMergeTagValue(target, profile) : undefined + const value = profile + ? optimization.withSdk((sdk) => sdk.getMergeTagValue(target, profile)) + : undefined if (value !== undefined) mergeTagResolved = true else mergeTagResolved ??= false return value ?? target.fields.nt_fallback diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index cbc23b58c..d24a3fe18 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -14,11 +14,11 @@ export class NgLiveUpdates { private readonly optimization = inject(NgContentfulOptimization) private readonly globalLiveUpdatesSignal = signal(false) - private readonly previewPanelAttached = fromSdkState( - () => this.optimization.sdk?.states.previewPanelAttached, + private readonly previewPanelAttached = fromSdkState(() => + this.optimization.withSdk((sdk) => sdk.states.previewPanelAttached), ) - private readonly previewPanelOpen = fromSdkState( - () => this.optimization.sdk?.states.previewPanelOpen, + private readonly previewPanelOpen = fromSdkState(() => + this.optimization.withSdk((sdk) => sdk.states.previewPanelOpen), ) readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly() diff --git a/implementations/web-sdk_angular/src/app/services/optimization-server.ts b/implementations/web-sdk_angular/src/app/services/optimization-server.ts deleted file mode 100644 index a9a850299..000000000 --- a/implementations/web-sdk_angular/src/app/services/optimization-server.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { TransferState } from '@angular/core' -import type ContentfulOptimization from '@contentful/optimization-node' -import type { OptimizationData } from '@contentful/optimization-node/api-schemas' -import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' -import type { Entry } from 'contentful' -import type { NgContentfulOptimizationConfig } from '../config' -import { resolveLogLevel } from '../config' -import { - SERVER_OPTIMIZATION_KEY, - SERVER_RESOLVED_ENTRIES_KEY, - type ResolvedEntryHandoff, - type ServerHandoff, -} from '../transfer-state-keys' - -const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' - -/** - * Reads a cookie value out of a Web `Request`. Mirrors the convention the - * `nextjs-sdk_ssr` reference uses for the `app-personalization-consent` and - * anonymous-id cookies. Returns `null` when the cookie is absent so callers - * can branch on a single contract instead of an `undefined` union. - */ -function readCookie(request: Request, name: string): string | null { - const header = request.headers.get('cookie') ?? '' - for (const part of header.split(';')) { - const trimmed = part.trim() - if (!trimmed) continue - const eq = trimmed.indexOf('=') - if (eq < 0) continue - if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1) - } - return null -} - -/** - * Construct the Node SDK instance. Dynamic-imported by the caller so the Node - * SDK and its Node-only dependencies stay out of the browser bundle. - */ -export async function createServerOptimization( - config: NgContentfulOptimizationConfig, -): Promise { - const { default: NodeContentfulOptimization } = await import('@contentful/optimization-node') - return new NodeContentfulOptimization({ - clientId: config.clientId, - environment: config.environment, - logLevel: resolveLogLevel(config.logLevel), - locale: config.locale, - app: config.app, - api: { - insightsBaseUrl: config.insightsBaseUrl, - experienceBaseUrl: config.experienceBaseUrl, - }, - }) -} - -/** - * Outcome of the server-side preflight for one SSR request. Discriminated on - * `consentGranted` so callers either get the full personalization context or - * a "no SDK work happened" branch — never a half-populated value. - */ -export type ServerOptimizationData = - | { - readonly consentGranted: false - } - | { - readonly consentGranted: true - readonly data: OptimizationData - readonly profileId: string - readonly canPersistProfile: boolean - } - -/** - * Run the SDK preflight for a single SSR request: bind the Node SDK to the - * inbound request's consent + anonymous-id cookies and emit the initial page - * event. Returns the resulting OptimizationData (selected optimizations, - * profile) for downstream resolution and TransferState handoff. - */ -export async function getServerOptimizationData( - sdk: ContentfulOptimization, - request: Request, - locale: string, -): Promise { - const consentGranted = readCookie(request, APP_PERSONALIZATION_CONSENT_COOKIE) === 'granted' - if (!consentGranted) return { consentGranted: false } - - const anonymousId = readCookie(request, ANONYMOUS_ID_COOKIE) - const requestOptimization = sdk.forRequest({ - consent: { events: true, persistence: true }, - locale, - ...(anonymousId === null ? {} : { profile: { id: anonymousId } }), - }) - const data = await requestOptimization.page() - if (!data) return { consentGranted: false } - - return { - consentGranted: true, - data, - profileId: data.profile.id, - canPersistProfile: requestOptimization.canPersistProfile, - } -} - -/** - * Resolve baseline entries against the server-side `selectedOptimizations` - * snapshot and return a TransferState-ready map keyed by baseline entry id. - */ -export function resolveServerEntries( - sdk: ContentfulOptimization, - baselines: readonly Entry[], - selectedOptimizations: OptimizationData['selectedOptimizations'], -): Record { - const resolved: Record = {} - for (const baseline of baselines) { - const result = sdk.resolveOptimizedEntry(baseline, selectedOptimizations) - resolved[baseline.sys.id] = { - baseline, - resolvedEntry: result.entry, - selectedOptimization: result.selectedOptimization ?? null, - } - } - return resolved -} - -/** - * Persist the SDK anonymous-id back to the client via Set-Cookie. Browser - * pages that hydrate after this read the cookie via the Web SDK on first - * `identify()`/`page()` so the same profile is observed across runtimes. - */ -export function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { - const headers = - responseInit.headers instanceof Headers - ? responseInit.headers - : new Headers(responseInit.headers) - headers.append('set-cookie', `${ANONYMOUS_ID_COOKIE}=${profileId}; Path=/; SameSite=Lax`) - responseInit.headers = headers -} - -/** - * Stamp the server preflight + per-entry resolution into TransferState so the - * browser hydration step can avoid duplicate fetches and re-resolutions. - */ -export function stampServerHandoff( - transferState: TransferState, - serverData: ServerOptimizationData, - resolvedEntries: Record, -): void { - const handoff: ServerHandoff = serverData.consentGranted - ? { - consent: true, - profile: serverData.data.profile, - profileId: serverData.profileId, - selectedOptimizations: serverData.data.selectedOptimizations, - } - : { consent: false } - transferState.set(SERVER_OPTIMIZATION_KEY, handoff) - transferState.set>( - SERVER_RESOLVED_ENTRIES_KEY, - resolvedEntries, - ) -} diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index 78ece40d1..5281ab247 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -1,17 +1,25 @@ import { isPlatformBrowser } from '@angular/common' import { + DestroyRef, inject, Injectable, PLATFORM_ID, + provideAppInitializer, + REQUEST, + RESPONSE_INIT, signal, TransferState, - type OnDestroy, + type EnvironmentProviders, type Signal, } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' +import type NodeContentfulOptimizationType from '@contentful/optimization-node' +import type { OptimizationData } from '@contentful/optimization-node/api-schemas' +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import ContentfulOptimization from '@contentful/optimization-web' import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { Subscription } from 'rxjs' +import type { Entry } from 'contentful' +import { PAGES } from 'e2e-web' import { filter } from 'rxjs/operators' import type { NgContentfulOptimizationConfig } from '../config' import { @@ -19,10 +27,52 @@ import { NG_CONTENTFUL_OPTIMIZATION_CONFIG, resolveLogLevel, } from '../config' -import { SERVER_OPTIMIZATION_KEY } from '../transfer-state-keys' +import { + SERVER_OPTIMIZATION_KEY, + SERVER_RESOLVED_ENTRIES_KEY, + type ResolvedEntryHandoff, + type ServerHandoff, +} from '../transfer-state-keys' import { fromSdkState } from '../utils' +import { NgContentfulClient } from './contentful-client' + +type NgContentfulOptimizationInstance = ContentfulOptimization + +/** + * Runtime context for {@link NgContentfulOptimization}. Discriminated on + * `platform` so callers branch on the runtime instead of dereferencing a + * `sdk?` chain. The `server` branch carries no SDK because the Web SDK reads + * `localStorage` at construction time and cannot be instantiated server-side. + */ +type NgContentfulOptimizationContext = + | { readonly platform: 'server' } + | { readonly platform: 'browser'; readonly sdk: NgContentfulOptimizationInstance } -export type NgContentfulOptimizationInstance = ContentfulOptimization +/** + * Shared SDK-config mapping used by both the browser Web SDK constructor and + * the server Node SDK constructor. The two SDK classes accept the same shape + * for these fields, so the mapping lives here once. + */ +function toSdkConstructorArgs(config: NgContentfulOptimizationConfig): { + clientId: string + environment: string + logLevel: 'debug' | 'warn' | 'error' + locale: string + app: NgContentfulOptimizationConfig['app'] + api: { insightsBaseUrl: string; experienceBaseUrl: string } +} { + return { + clientId: config.clientId, + environment: config.environment, + logLevel: resolveLogLevel(config.logLevel), + locale: config.locale, + app: config.app, + api: { + insightsBaseUrl: config.insightsBaseUrl, + experienceBaseUrl: config.experienceBaseUrl, + }, + } +} let instance: NgContentfulOptimizationInstance | undefined = undefined let attachmentStarted = false @@ -51,54 +101,45 @@ function getOrCreateInstance( config: NgContentfulOptimizationConfig, ): NgContentfulOptimizationInstance { instance ??= new ContentfulOptimization({ - clientId: config.clientId, - environment: config.environment, - logLevel: resolveLogLevel(config.logLevel), + ...toSdkConstructorArgs(config), autoTrackEntryInteraction: config.autoTrackEntryInteraction ?? { views: true, clicks: true, hovers: true, }, - locale: config.locale, - app: config.app, - api: { - insightsBaseUrl: config.insightsBaseUrl, - experienceBaseUrl: config.experienceBaseUrl, - }, }) return instance } /** - * Browser-only SDK service. The Web SDK constructor touches `localStorage` at - * construction time, so on the server we leave `sdk` as `undefined` and skip - * SDK side effects. Components dereferencing `sdk?.` are no-ops during SSR; - * the same components run normally after hydration once the browser SDK is - * constructed here. + * Single SDK service exposed to components. On the browser it owns a real + * {@link ContentfulOptimization} instance; on the server it surfaces the SSR + * handoff so templates render the personalised state without ever touching the + * Web SDK (which would crash on `localStorage`). */ @Injectable({ providedIn: 'root' }) -export class NgContentfulOptimization implements OnDestroy { - readonly sdk: NgContentfulOptimizationInstance | undefined +export class NgContentfulOptimization { + readonly context: NgContentfulOptimizationContext readonly consent: Signal readonly profile: Signal readonly selectedOptimizations: Signal - private readonly routerSubscription: Subscription | undefined - constructor() { const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) const router = inject(Router) + const destroyRef = inject(DestroyRef) const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) if (!isBrowser) { - // On the server, seed the read-only signals from the SSR handoff so - // server-rendered templates reflect the same consent/profile state the - // server preflight observed. Without this, JS-disabled clients would - // see "undefined" / "0 active optimizations" in the Utilities panel - // even though the entry markup is fully personalized. + // Seed the read-only signals from the SSR handoff so server-rendered + // templates reflect the same consent/profile state the server preflight + // observed. Without this, JS-disabled clients would see "undefined" / + // "0 active optimizations" in the Utilities panel even though the entry + // markup is fully personalised. const handoff = inject(TransferState).get(SERVER_OPTIMIZATION_KEY, undefined) - this.sdk = undefined - this.consent = signal(handoff?.consent === true).asReadonly() + const isGranted = handoff?.consent === true + this.context = { platform: 'server' } + this.consent = signal(isGranted).asReadonly() this.profile = signal( handoff?.consent === true ? handoff.profile : undefined, ).asReadonly() @@ -108,31 +149,203 @@ export class NgContentfulOptimization implements OnDestroy { return } - this.sdk = getOrCreateInstance(config) + const sdk = getOrCreateInstance(config) + this.context = { platform: 'browser', sdk } if (config.previewPanel !== undefined) { - void attachPreviewPanel(this.sdk, config) + void attachPreviewPanel(sdk, config) } - this.consent = fromSdkState(this.sdk.states.consent) - this.profile = fromSdkState(this.sdk.states.profile) - this.selectedOptimizations = fromSdkState(this.sdk.states.selectedOptimizations) + this.consent = fromSdkState(sdk.states.consent) + this.profile = fromSdkState(sdk.states.profile) + this.selectedOptimizations = fromSdkState(sdk.states.selectedOptimizations) // Page events must fire on every route change including the initial load. // The SDK uses the current URL to resolve which experiences apply to the user. - this.routerSubscription = router.events + const routerSubscription = router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => { - void this.sdk?.page({ + void sdk.page({ properties: { url: window.location.origin + e.urlAfterRedirects }, }) }) + + destroyRef.onDestroy(() => { + routerSubscription.unsubscribe() + sdk.destroy() + instance = undefined + attachmentStarted = false + }) } - ngOnDestroy(): void { - this.routerSubscription?.unsubscribe() - this.sdk?.destroy() - instance = undefined - attachmentStarted = false + /** + * Run an SDK side-effect on the browser. Returns the callback's value on the + * browser branch, and `undefined` on the server (where there is no SDK to + * call). Lets call sites avoid an `if (context.platform === 'browser')` + * narrowing dance for fire-and-forget toggles. + */ + withSdk(fn: (sdk: NgContentfulOptimizationInstance) => T): T | undefined { + return this.context.platform === 'browser' ? fn(this.context.sdk) : undefined } } + +// ── Server-side preflight ────────────────────────────────────────────────── +// +// The helpers below run only on the server (in the @angular/ssr render +// pipeline) and dynamic-import @contentful/optimization-node so the Node SDK +// never reaches the browser bundle. They are exposed via +// `provideServerOptimizationInitializer()` so `app.config.server.ts` only +// needs a single import to wire them in. + +const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' + +/** + * Outcome of looking up a request cookie. Discriminated on `found` so the + * value is only accessible after the caller proves the cookie exists. + */ +type CookieLookup = { readonly found: false } | { readonly found: true; readonly value: string } + +const COOKIE_NOT_FOUND: CookieLookup = { found: false } + +function readCookie(request: Request, name: string): CookieLookup { + const header = request.headers.get('cookie') ?? '' + for (const part of header.split(';')) { + const trimmed = part.trim() + if (!trimmed) continue + const eq = trimmed.indexOf('=') + if (eq < 0) continue + if (trimmed.slice(0, eq) === name) return { found: true, value: trimmed.slice(eq + 1) } + } + return COOKIE_NOT_FOUND +} + +async function createServerOptimization( + config: NgContentfulOptimizationConfig, +): Promise { + const { default: NodeContentfulOptimization } = await import('@contentful/optimization-node') + return new NodeContentfulOptimization(toSdkConstructorArgs(config)) +} + +/** + * Outcome of the server-side preflight for one SSR request. Discriminated on + * `consentGranted` so callers either get the full personalization context or + * a "no SDK work happened" branch — never a half-populated value. + */ +type ServerOptimizationData = + | { readonly consentGranted: false } + | { + readonly consentGranted: true + readonly data: OptimizationData + readonly profileId: string + readonly canPersistProfile: boolean + } + +async function getServerOptimizationData( + sdk: NodeContentfulOptimizationType, + request: Request, + locale: string, +): Promise { + const consentCookie = readCookie(request, APP_PERSONALIZATION_CONSENT_COOKIE) + const consentGranted = consentCookie.found && consentCookie.value === 'granted' + if (!consentGranted) return { consentGranted: false } + + const anonymousId = readCookie(request, ANONYMOUS_ID_COOKIE) + const requestOptimization = sdk.forRequest({ + consent: { events: true, persistence: true }, + locale, + ...(anonymousId.found ? { profile: { id: anonymousId.value } } : {}), + }) + const data = await requestOptimization.page() + if (!data) return { consentGranted: false } + + return { + consentGranted: true, + data, + profileId: data.profile.id, + canPersistProfile: requestOptimization.canPersistProfile, + } +} + +function resolveServerEntries( + sdk: NodeContentfulOptimizationType, + baselines: readonly Entry[], + selectedOptimizations: OptimizationData['selectedOptimizations'], +): Record { + const resolved: Record = {} + for (const baseline of baselines) { + const result = sdk.resolveOptimizedEntry(baseline, selectedOptimizations) + resolved[baseline.sys.id] = result.selectedOptimization + ? { + isVariant: true, + baseline, + resolvedEntry: result.entry, + selectedOptimization: result.selectedOptimization, + } + : { isVariant: false, baseline, resolvedEntry: result.entry } + } + return resolved +} + +function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { + const headers = + responseInit.headers instanceof Headers + ? responseInit.headers + : new Headers(responseInit.headers) + headers.append('set-cookie', `${ANONYMOUS_ID_COOKIE}=${profileId}; Path=/; SameSite=Lax`) + responseInit.headers = headers +} + +function stampServerHandoff( + transferState: TransferState, + serverData: ServerOptimizationData, + resolvedEntries: Record, +): void { + const handoff: ServerHandoff = serverData.consentGranted + ? { + consent: true, + profile: serverData.data.profile, + profileId: serverData.profileId, + selectedOptimizations: serverData.data.selectedOptimizations, + } + : { consent: false } + transferState.set(SERVER_OPTIMIZATION_KEY, handoff) + transferState.set>( + SERVER_RESOLVED_ENTRIES_KEY, + resolvedEntries, + ) +} + +async function runServerPreflight(): Promise { + const request = inject(REQUEST, { optional: true }) + if (!request) return + + const responseInit = inject(RESPONSE_INIT, { optional: true }) + const transferState = inject(TransferState) + const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) + const contentful = inject(NgContentfulClient) + + const sdk = await createServerOptimization(config) + const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] + const baselines = await contentful.fetchEntries(baselineIds) + + const serverData = await getServerOptimizationData(sdk, request, config.locale) + + if (serverData.consentGranted && serverData.canPersistProfile && responseInit) { + persistAnonymousIdCookie(responseInit, serverData.profileId) + } + + const resolvedEntries = resolveServerEntries( + sdk, + baselines, + serverData.consentGranted ? serverData.data.selectedOptimizations : [], + ) + stampServerHandoff(transferState, serverData, resolvedEntries) +} + +/** + * Wires the server-side SDK preflight into Angular's application + * initializers. Imported from `app.config.server.ts`. + */ +export function provideServerOptimizationInitializer(): EnvironmentProviders { + return provideAppInitializer(runServerPreflight) +} diff --git a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts index f164a6205..af083ff0a 100644 --- a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts +++ b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts @@ -24,13 +24,22 @@ export type ServerHandoff = /** * Per-baseline-entry result of `sdk.resolveOptimizedEntry()` carried across * the hydration boundary. The browser uses these to skip a duplicate Experience - * API roundtrip on initial render. + * API roundtrip on initial render. Discriminated on `isVariant` so callers + * branch on the variant/baseline distinction without consulting a nullable + * field. */ -export interface ResolvedEntryHandoff { - readonly baseline: Entry - readonly resolvedEntry: Entry - readonly selectedOptimization: SelectedOptimization | null -} +export type ResolvedEntryHandoff = + | { + readonly isVariant: false + readonly baseline: Entry + readonly resolvedEntry: Entry + } + | { + readonly isVariant: true + readonly baseline: Entry + readonly resolvedEntry: Entry + readonly selectedOptimization: SelectedOptimization + } export const SERVER_OPTIMIZATION_KEY: StateKey = makeStateKey('ssr-optimization')