Skip to content

🔥 feat(web-sdk_angular): Add Angular Universal (SSR) support [NT-3467]#330

Open
Felipe Mamud (fmamud) wants to merge 8 commits into
mainfrom
NT-3467-angular-ssr
Open

🔥 feat(web-sdk_angular): Add Angular Universal (SSR) support [NT-3467]#330
Felipe Mamud (fmamud) wants to merge 8 commits into
mainfrom
NT-3467-angular-ssr

Conversation

@fmamud

Copy link
Copy Markdown
Contributor

Summary

  • Extends implementations/web-sdk_angular with Angular Universal SSR. Server-side SDK initialisation uses @contentful/optimization-node via a conditional dynamic import; the browser
    keeps using @contentful/optimization-web unchanged.
  • Resolved entries cross the hydration boundary through TransferState. Anonymous-id continuity is preserved across runtimes via a Set-Cookie written by the server preflight.
  • HTTP layer follows the existing node-sdk reference: express@5.2.1 + express-rate-limit@8.2.1, mounted on @angular/ssr/node's AngularNodeAppEngine.

What changed

  • SSR boilerplatesrc/main.server.ts, src/server.ts, src/app/app.config.server.ts, src/app/app.routes.server.ts. angular.json gains server, outputMode: "server", and
    ssr.entry. package.json adds @angular/ssr, @angular/platform-server, express, express-rate-limit, and a start script.
  • Server-side SDK preflightsrc/app/services/optimization-server.ts dynamic-imports @contentful/optimization-node, reads consent + anonymous-id cookies from Angular's REQUEST
    token, calls forRequest({ consent }).page(), runs resolveOptimizedEntry() per baseline, persists the anonymous-id back via RESPONSE_INIT, and stamps TransferState.
  • HydrationprovideClientHydration(withEventReplay()) on the browser side. src/app/services/entry.ts falls back to TransferState when the browser SDK is absent (server runtime).
    src/app/services/contentful-client.ts loadEntries short-circuits when the server already fetched the baselines.
  • Browser-only SDK serviceNgContentfulOptimization constructs @contentful/optimization-web only when isPlatformBrowser. SDK call sites in components and services use null-safe
    chains so server render skips browser-only side effects without a separate adapter type.

Why

Per the ticket implementation notes, "the existing browser SDK works cleanly in the SSR context without special-casing" was preferred — but the browser SDK touches localStorage at construction, which crashes server-side. The fallback the notes explicitly allow is used: @contentful/optimization-node for server-side SDK initialisation, conditional dynamic imports for runtime separation. There are no unsafe type assertions: server modules use the Node SDK with its real types; browser modules use the Web SDK with its real types; only ResolvedEntry data crosses the boundary, via TransferState.

A future Angular adapter package will extract these helpers into @contentful/optimization-angular and replace the in-impl wiring.

Test plan

  • pnpm implementation:run -- web-sdk_angular typecheck
  • pnpm exec eslint implementations/web-sdk_angular
  • pnpm implementation:run -- web-sdk_angular build — emits dist/web-sdk_angular/{browser,server}/
  • Dev server smoke (pnpm implementation:run -- web-sdk_angular dev + mocks):
    • Initial HTML carries ng-server-context="ssr"
    • With cookie: app-personalization-consent=granted: data-ctfl-variant-index, data-ctfl-optimization-id populated server-side; Set-Cookie: ctfl-opt-aid=… returned
    • Without consent: server skips Experience API; data-ctfl-baseline-id rendered; browser SDK takes over after hydration
    • /page-two route also serves SSR'd HTML
    • Server log shows Resolving optimized entry for baseline entry … and has been resolved to variant entry … lines per request
  • Reviewer: open http://localhost:4200 in a browser and exercise consent / identify / reset / live-updates / preview panel post-hydration
  • Reviewer: run shared E2E suite — pnpm test:e2e:web-sdk_angular (existing scenarios were authored against the SPA; SSR-specific assertions are intentionally deferred)

@wiz-inc-38d59fb8d7

wiz-inc-38d59fb8d7 Bot commented Jun 23, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total -

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

* call). Lets call sites avoid an `if (context.platform === 'browser')`
* narrowing dance for fire-and-forget toggles.
*/
withSdk<T>(fn: (sdk: NgContentfulOptimizationInstance) => T): T | undefined {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't say it's a blocker, but I was definitely confused by what withSdk was supposed to me until I read the comment. Maybe there's a name we could use that would better, but still concisely, describe its purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, so renamed to ifBrowser across all call sites.

this.optimization.ifBrowser((sdk) => sdk.identify(...)).

Comment on lines +42 to +45
* 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.

@phobetron Charles Hudson (phobetron) Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing some context, but we can use the Node SDK on the server side and pass its results to the client side, unless I'm missing something specific to this use case. I can see we're using the Node SDK later on in this file, so I guess I'm missing a nuance in this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right, the comment was misleading. Rewrote the docstring to make that split explicit, since the Node SDK does run server-side, and the service's context.sdk field only ever holds the browser Web SDK.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be better when we provide @contentful/optimization-angular package, so that this will be similar to NestJS reference impl.

// `provideServerOptimizationInitializer()` so `app.config.server.ts` only
// needs a single import to wire them in.

const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'

@phobetron Charles Hudson (phobetron) Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether lower-level cookie handling can/should be moved out of the optimization service context, which I suppose could be similarly true for other reference implementations. It might make more sense regarding why this is not handled by the SDK, if we do not consider this cookie to be a managed part of the optimization process. The SDK can adjust its behavior on whether consent was granted, but the SDK can not determine how consent was gathered. Gathering consent, even granularly, is a consumer application responsibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I've xtracted app-level consent cookio into a new services/consent.ts and the browser-side ConsentCookie service writes via Angular's DOCUMENT injection token.

Comment on lines +9 to +42
/**
* 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
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether this extra restructuring of SDK data might be overkill, or why we can't simply use the data as it's returned by the SDK.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, I agree. I've dropped the discriminated union and baseline field. Baselines moved to a separate SERVER_BASELINES_KEY so each slot stays semantically clean.

@fmamud

Copy link
Copy Markdown
Contributor Author

Charles Hudson (@phobetron) reviews has been addressed, could you check again pls?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants