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..58a98a194 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -27,11 +27,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.2", "rxjs": "~7.8.0", "tslib": "^2.8.1" }, @@ -40,6 +45,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..c0f51d2ea --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.config.server.ts @@ -0,0 +1,14 @@ +import { mergeApplicationConfig, type ApplicationConfig } from '@angular/core' +import { provideServerRendering, withRoutes } from '@angular/ssr' +import { appConfig } from './app.config' +import { serverRoutes } from './app.routes.server' +import { provideServerOptimizationInitializer } from './services/optimization' + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(withRoutes(serverRoutes)), + provideServerOptimizationInitializer(), + ], +} + +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..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 @@ -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', @@ -22,21 +36,34 @@ 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.withSdk((sdk) => 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) + 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 659782542..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 @@ -50,9 +50,14 @@ export class TrackingLog { }) constructor() { + const { optimization } = this + const { context } = optimization + if (context.platform !== 'browser') return + const { sdk } = context + 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..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/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..f5ba96a1d 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 { entryId, optimizationId, sticky, variantIndex } = result() - optimization.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 { - optimization.sdk.tracking.clearElement('views', elementRef.nativeElement) + optimization.withSdk((sdk) => { + 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,12 +127,29 @@ export function injectContentfulEntry({ const variant = computed(() => { const raw = entry() + if (optimization.context.platform === 'browser') { + return { + raw, + resolved: optimization.context.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] + if (slot?.isVariant) { + return { + raw, + resolved: { entry: slot.resolvedEntry, selectedOptimization: slot.selectedOptimization }, + } + } return { raw, - resolved: optimization.sdk.resolveOptimizedEntry( - raw, - liveRead(optimization.selectedOptimizations), - ), + resolved: { entry: slot?.resolvedEntry ?? raw, selectedOptimization: undefined }, } }) @@ -134,7 +158,9 @@ export function injectContentfulEntry({ const profile = liveRead(optimization.profile) let mergeTagResolved: boolean | undefined = undefined const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = profile ? optimization.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 b8dc44180..d24a3fe18 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, + private readonly previewPanelAttached = fromSdkState(() => + this.optimization.withSdk((sdk) => sdk.states.previewPanelAttached), + ) + private readonly previewPanelOpen = fromSdkState(() => + this.optimization.withSdk((sdk) => 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.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index 912f224f0..5281ab247 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -1,8 +1,25 @@ -import { inject, Injectable, type OnDestroy, type Signal } from '@angular/core' +import { isPlatformBrowser } from '@angular/common' +import { + DestroyRef, + inject, + Injectable, + PLATFORM_ID, + provideAppInitializer, + REQUEST, + RESPONSE_INIT, + signal, + TransferState, + 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 { @@ -10,9 +27,52 @@ import { NG_CONTENTFUL_OPTIMIZATION_CONFIG, resolveLogLevel, } from '../config' +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 -export 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 } + +/** + * 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 @@ -41,62 +101,251 @@ 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 } +/** + * 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 +export class NgContentfulOptimization { + readonly context: NgContentfulOptimizationContext readonly consent: Signal readonly profile: Signal readonly selectedOptimizations: Signal - private readonly routerSubscription: Subscription - constructor() { const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) const router = inject(Router) + const destroyRef = inject(DestroyRef) + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) - this.sdk = getOrCreateInstance(config) - - if (config.previewPanel !== undefined) { - void attachPreviewPanel(this.sdk, config) + if (!isBrowser) { + // 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) + const isGranted = handoff?.consent === true + this.context = { platform: 'server' } + this.consent = signal(isGranted).asReadonly() + this.profile = signal( + handoff?.consent === true ? handoff.profile : undefined, + ).asReadonly() + this.selectedOptimizations = signal( + handoff?.consent === true ? handoff.selectedOptimizations : undefined, + ).asReadonly() + return } - this.consent = fromSdkState(this.sdk.states.consent) + const sdk = getOrCreateInstance(config) + this.context = { platform: 'browser', sdk } - this.profile = fromSdkState(this.sdk.states.profile) + if (config.previewPanel !== undefined) { + void attachPreviewPanel(sdk, config) + } - 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({ properties: { url: window.location.origin + e.urlAfterRedirects } }) + void sdk.page({ + properties: { url: window.location.origin + e.urlAfterRedirects }, + }) }) + + destroyRef.onDestroy(() => { + routerSubscription.unsubscribe() + 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 } - ngOnDestroy(): void { - this.routerSubscription.unsubscribe() - this.sdk.destroy() - instance = undefined - attachmentStarted = 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 new file mode 100644 index 000000000..af083ff0a --- /dev/null +++ b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts @@ -0,0 +1,48 @@ +import { makeStateKey, type StateKey } from '@angular/core' +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 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 + * the hydration boundary. The browser uses these to skip a duplicate Experience + * API roundtrip on initial render. Discriminated on `isVariant` so callers + * branch on the variant/baseline distinction without consulting a nullable + * field. + */ +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') + +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..8d7f48c77 --- /dev/null +++ b/implementations/web-sdk_angular/src/server.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-console */ +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' + +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 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'