diff --git a/docs/spa-bff-integration-guide.md b/docs/spa-bff-integration-guide.md new file mode 100644 index 00000000000..21adbb80085 --- /dev/null +++ b/docs/spa-bff-integration-guide.md @@ -0,0 +1,768 @@ +# Spartacus + BFF — Replacing Direct OCC Integration + +> **The story this document tells:** +> A Spartacus storefront that talks directly to OCC works — but the browser +> makes multiple sequential round trips to assemble a single page, credentials +> are visible in DevTools, error messages are meaningless, and every OCC API +> change requires a coordinated storefront release. This document shows each of +> those problems with working code and demonstrates how a BFF fixes them. It is +> honest about what the current implementation does and does not yet improve. + +> **Prerequisite:** This document assumes the base BFF integration from +> [spa-bff-reference-implementation.md](./spa-bff-reference-implementation.md) +> is already in place. That guide covers setting up the Vivaldi BFF workspace, +> wiring `BffClientService`, configuring the Angular dev-server proxy, and the +> CCv2 deployment model. The overrides described here build directly on that +> baseline. + +--- + +--- + +## Table of Contents + +- [Two approaches to BFF integration](#two-approaches-to-bff-integration) +- [Which approach should I choose?](#which-approach-should-i-choose) +- [The performance problem — PDP sequential calls](#the-performance-problem) +- [The security problem — credentials reach the browser](#the-security-problem) +- [The correctness problems — errors and localisation](#the-correctness-problems) +- [What BFF fixes](#what-bff-fixes) +- [How the overrides work](#how-the-overrides-work) +- [The Angular DI problem](#the-angular-di-problem) +- [Running and testing](#running-and-testing) +- [Improvements borrowed from the spartacus-bff reference](#improvements-borrowed-from-the-spartacus-bff-reference) +- [File layout](#file-layout) +- [Approach 2 — Facade-level override](#approach-2--facade-level-override) + +--- + +## Two approaches to BFF integration + +There are two distinct ways to integrate a BFF with a Spartacus storefront. +Understanding the difference matters before reading the rest of this document. + +### Approach A — Connector override (this document) + +The starting point is a **standard Spartacus Classic storefront**. The BFF is +introduced as a drop-in replacement at the connector layer, without touching +UI components, NgRx state, effects, or facades. Everything above the connector +continues to work exactly as before. + +``` +Standard Spartacus stack: + AddToCartComponent → ActiveCartService → NgRx effects → CartConnector + ↓ + [replaced: BffCartConnector] + ↓ + BffClientService → tRPC → BFF → OCC +``` + +The rest of the OCC layer (`OccCartAdapter`, normalizers, endpoint config) is +still in the bundle — it is simply no longer called. The BFF is bolted on. + +**What this gives you:** +- Incremental migration — one feature at a time, no rewrite required +- All existing Spartacus components, customisations, and upgrade paths stay intact +- A BFF can be introduced into a running production storefront + +**What it does not give you:** +- Fully clean architecture — unused OCC code remains in the browser bundle +- Complete ownership separation — the storefront still carries OCC concepts + (normalizers, adapter interfaces, endpoint keys) even if it no longer calls them + +**This is what this document describes.** + +--- + +### Approach B — Purpose-built BFF storefront (spartacus-bff) + +The starting point is **not a standard Spartacus storefront**. The entire OCC +layer is replaced by purpose-built Angular libraries (`@spartacus-bff/cart`, +`@spartacus-bff/checkout`, etc.) designed from scratch to talk to BFF via tRPC. +There are no `OccCartAdapter`, no `CartConnector`, no normalizer chains. + +``` +spartacus-bff stack: + AddToCartComponent → CartFacade (CartService) + ↓ + injectTRPCClient() ← typed tRPC client, Angular DI + ↓ + client.spartacus.mcs.auth.cart.addToCart.v1.mutateState$() + ↓ + BFF → OCC +``` + +`CartRootModule.forRoot(injectTRPCClient)` wires the typed client directly into +Angular DI. The facade calls BFF procedures directly — no adapter, no connector, +no NgRx effects between the facade and the network call. + +**What this gives you:** +- Clean architecture — the storefront has no knowledge of OCC at all +- Type safety from UI component to OCC response, end-to-end +- BFF procedures are proper server-side business logic with converters, error + handling, and retries — not proxies +- No OCC dead code in the browser bundle + +**What it requires:** +- A full adoption of the `@spartacus-bff/*` library suite — no incremental path +- The BFF procedures become the published API surface; changes are versioned + +A reference implementation of this approach is available in the +`spartacus-bff` workspace. + +--- + +### The relationship between them + +**Approach A** is the migration path — it is how an existing customer on +Spartacus Classic gains BFF benefits without rebuilding their storefront. The +Angular DI gymnastics documented below (lazy chunk overrides, inline class +definitions) exist precisely because BFF is being bolted onto an architecture +designed for direct OCC. + +**Approach B** is the target architecture — what a new storefront would be +built on, or what an existing storefront would move to over time by replacing +feature modules one by one with their `@spartacus-bff/*` equivalents. + +This document is about **Approach A** — demonstrating that the migration is +viable, documenting the DI problems you encounter, and showing the concrete +benefits even in the connector-override form. + +--- + +## Which approach should I choose? + +### Choose Approach A (connector override) if: + +**You have an existing Spartacus Classic storefront in production.** +You cannot stop the world for a rewrite. Connector overrides let you introduce +BFF one feature at a time — cart this sprint, product next quarter, checkout +when you're ready — without touching UI components or breaking existing +customisations. + +**Your team knows Spartacus Classic well.** +The connector override keeps all the familiar Spartacus patterns intact. NgRx, +facades, normalizers, the CMS — everything works exactly as before. The only +new concept is a BFF procedure replacing an OCC adapter method. + +**You need to demonstrate BFF value quickly.** +The PDP aggregation can be shipped in days. You get a measurable performance +improvement (3× → 1× latency on Slow 3G) without a rewrite, which is a +compelling proof-of-concept for stakeholders. + +**You are on CCv2 and need incremental deployment.** +The connector override is a storefront-side change only. No new application +type in the Hosting Portal until you are ready. The BFF can run as a sidecar +while the classic OCC integration remains the fallback. + +--- + +### Choose Approach B (spartacus-bff / purpose-built) if: + +**You are starting a new storefront from scratch.** +There is no migration cost. You start clean: no OCC dead code in the bundle, +no adapter interfaces to carry, no normalizer chains. The `@spartacus-bff/*` +libraries give you a production-grade, versioned API from day one. + +**Your team's primary skill is modern Angular rather than Spartacus Classic.** +Approach B removes most of the Spartacus-specific indirection. `CartService` +calls BFF procedures directly via `this.client.spartacus.mcs.auth.cart.addToCart.v1.mutateState$()`. +There is no NgRx action to dispatch, no effect to configure, no adapter to +implement. TypeScript enforces the contract end-to-end. + +**You need the full architectural benefit — no OCC in the browser at all.** +With connector override, `OccCartAdapter`, normalizers, and endpoint configs +are still in the bundle — they just do not get called. With Approach B they +do not exist in the frontend at all. Bundle size, security posture, and upgrade +isolation are all cleaner. + +**You are building a multi-backend storefront (OCC + ERP + custom services).** +`spartacus-bff` procedures are real server-side business logic: they can +aggregate, transform, and merge from multiple upstreams before sending one +clean payload to the browser. + +--- + +### The honest middle ground + +Neither approach is universally better. The practical recommendation is: + +1. **Start with Approach A** to prove BFF value to stakeholders using existing + infrastructure. The PDP aggregation demo makes the case concretely without + requiring a rewrite commitment. + +2. **Plan Approach B as the migration target.** As features are rebuilt or + major Spartacus upgrades happen, replace each feature module with its + `@spartacus-bff/*` equivalent. The BFF procedures stay the same — only the + Angular wiring changes. + +3. **The BFF procedures are portable between both approaches.** The `cart.ts` + and `product.ts` routers work identically regardless of whether the + storefront uses connector overrides or `@spartacus-bff/*` libraries. + Starting with Approach A does not waste the BFF investment. + +## The performance problem + +### Product Detail Page — three sequential calls + +When a user opens a product detail page, Spartacus dispatches three independent +NgRx actions. Each triggers its own HTTP call to OCC, and each waits for the +previous to complete: + +``` +Browser → OCC: GET /occ/v2/{baseSite}/products/{code}?fields=... (600 ms) + ↓ waits +Browser → OCC: GET /occ/v2/{baseSite}/products/{code}/references (500 ms) + ↓ waits +Browser → OCC: GET /occ/v2/{baseSite}/products/{code}/reviews (400 ms) + ↓ +Total time visible to user: ~1500 ms +``` + +This is a fundamental constraint of the browser-side architecture. The browser +cannot fan out calls in parallel to the same origin without facing HTTP/1.1 +connection limits, and each effect dispatches independently so there is no +opportunity to batch them. + +### BFF aggregation — one call, parallel execution + +With `BffProductModule` active, the same page load becomes: + +``` +Browser → BFF: POST /bff/api/product.getPageData (one call) + BFF (parallel, Node.js): + ├── GET /occ/v2/.../products/{code}?fields=... + ├── GET /occ/v2/.../products/{code}/references + └── GET /occ/v2/.../products/{code}/reviews + BFF: merge → { product, references, reviews } +Browser receives: one response + +Total time visible to user: max(600, 500, 400) ms ≈ 600 ms +``` + +The browser makes one call and waits for the slowest OCC call, not the sum. +Node.js handles the three outgoing OCC requests concurrently. + +### How to measure it + +1. Open DevTools → Network → set throttling to **Slow 3G** +2. Navigate to a product page (e.g. `/electronics-spa/en/USD/Open-Catalogue/Cameras/Digital-Cameras/c/578?productCode=3325048`) + +**With `USE_AGGREGATION = false`** in `bff-product.module.ts`: +Filter Network by `/bff/api` — observe three sequential requests: +`product.getProduct`, `product.getReferences`, `product.getReviews`. +Each starts only after the previous completes. + +**With `USE_AGGREGATION = true`** (default): +Filter Network by `/bff/api` — observe one request: `product.getPageData`. +It returns all three datasets in a single response. + +The BFF console logs both modes: + +``` +// USE_AGGREGATION = false — three calls +BFF Product getProduct → OCC GET /electronics-spa/products/3325048 +BFF Product getReferences → OCC GET /electronics-spa/products/3325048/references +BFF Product getReviews → OCC GET /electronics-spa/products/3325048/reviews + +// USE_AGGREGATION = true — one call, parallel fan-out +BFF Product getPageData → 3 parallel OCC calls +BFF Product getPageData ✓ {product: {...}, references: [...], reviews: [...]} +``` + +--- + +## The security problem + +### Credentials reach the browser + +With direct OCC, Spartacus fetches a client credentials token and stores it in +browser memory to authenticate anonymous cart and product requests. Open +DevTools → Network on any anonymous OCC request: + +``` +Authorization: Bearer eyJraWQiOiI0YTA4... ← token visible in browser +GET https://api.your-tenant.myhybris.cloud/occ/v2/... ← OCC URL visible +``` + +The OCC base URL is also embedded in `index.html`: + +```html + +``` + +**With BFF:** the storefront sends no token for anonymous sessions. The BFF +holds `OCC_CLIENT_ID` and `OCC_CLIENT_SECRET` in `apps/bff/.env`. The +`index.html` entry is a relative path that reveals nothing: + +```html + +``` + +The storefront's OCC URL and the BFF's OCC URL are fully independent — they +can point at different OCC instances. + +--- + +## The correctness problems + +### OCC errors become generic 502s + +With a naive BFF proxy, every OCC error surfaces as a generic 502: + +``` +POST /bff/api/cart.load +← 502 TRPCClientError: "Unhandled upstream request error" + upstream.status: 404, upstream.body: null ← OCC error body lost +``` + +A 404 (stale cart — create a new one), 401 (token expired — re-auth), and 400 +(bad input — show error) all look identical to Spartacus. All routes to the +same generic error path. + +**With `normalizeUpstreamError`** (see [Improvements](#improvements-borrowed-from-spartacus-bff)): + +``` +POST /bff/api/cart.load ← stale cart GUID +← tRPCError { code: "NOT_FOUND" } +→ HttpErrorResponse { status: 404 } +→ Spartacus effect creates a fresh cart ✓ +``` + +### Localisation is silently broken + +Spartacus's `SiteContextInterceptor` adds `lang` and `curr` query parameters +but does not forward the browser's `Accept-Language` header, which OCC uses +for response text localisation. The original BFF implementation only forwarded +`authorization` — `Accept-Language` was silently dropped. + +**With `commonHeaders` wrapper**, every BFF procedure forwards both headers so +OCC responds in the user's browser locale. + +--- + +## What BFF fixes + +| Concern | Direct OCC | BFF (this implementation) | +|---------|--------------------------|---------------------------| +| PDP sequential calls (3× latency) | ✗ unavoidable in browser | ✓ parallel server-side, 1× latency | +| Client credentials in browser | ✗ visible in DevTools | ✓ server-side only | +| OCC URL in `index.html` | ✗ exposes backend | ✓ relative `/bff/api` | +| OCC error semantics | ✓ HttpErrorResponse | ✓ preserved via normalizeUpstreamError | +| Generic 502 on upstream errors | ✗ yes (naive proxy) | ✓ fixed | +| `Accept-Language` forwarded | ✗ silently dropped | ✓ commonHeaders wrapper | +| Stale cart GUID recovery | ✗ partial (anon only) | ✓ any 404 → new cart | +| Anonymous cart `guid` vs `code` | N/A | ✓ `guid ?? code` in all write ops | +| "Added to Cart" dialog empty (Approach 2) | N/A | ✓ dispatch `CartAddEntrySuccessEvent` manually | +| Type-safe procedure contract | ✗ manual HTTP | ✓ tRPC end-to-end | +| Storefront OCC ≠ BFF OCC | ✗ same instance | ✓ fully independent | + +--- + +## How the overrides work + +### Product (PDP aggregation) + +`BffProductModule` overrides all three product connectors in the **root +injector**. Unlike the cart (which required a lazy chunk workaround — see +[Angular DI problem](#the-angular-di-problem)), product connectors are all +`providedIn: 'root'` with no lazy re-provision in `ProductOccModule`. A simple +root-level override works: + +``` +ProductConnector → BffProductConnectorImpl +ProductReferencesConnector → BffReferencesConnectorImpl +ProductReviewsConnector → BffReviewsConnectorImpl +``` + +The toggle in `bff-product.module.ts`: + +```ts +// false = 3 individual BFF calls (same count as direct OCC) +// true = 1 aggregated BFF call (the performance win) +const USE_AGGREGATION = true; +``` + +### Cart (connector-level override) + +`BffCartBaseModule` overrides `CartConnector` and `CartEntryConnector` inside +the lazy cart feature injector. All standard cart UI — add-to-cart button, +mini-cart, cart page, quantity update, remove — routes through BFF unchanged. + +``` +User clicks "Add to Cart" + └── AddToCartComponent + └── ActiveCartService.addEntry() [NgRx dispatch] + └── CartEntryEffects [lazy chunk] + └── CartEntryConnector.add() [overridden] + └── POST /bff/api/cart.addEntry + └── BFF Node → OCC +``` + +--- + +## The Angular DI problem + +The cart override is more complex than the product override because of how +Spartacus lazy-loads `CartBaseModule`. This section documents three failed +approaches so anyone extending the pattern does not repeat them. + +### Three approaches that failed + +**1. Root injector, adapter level.** +`{ provide: CartAdapter, useClass: BffCartAdapter }` in `AppModule`. +The Angular bundler places `CartAdapter` as two different JS class objects — +one in the main bundle, one in the lazy `CartBaseModule` chunk. The root-level +binding targets the main bundle copy. The effects inject from their own copy. +The override is invisible. + +**2. Lazy wrapper module, separate file.** +`BffCartBaseModule` wrapping `CartBaseModule`, registered as the +`CART_BASE_FEATURE` lazy module, with `BffCartAdapter` in a separate file. +The bundler splits `BffCartAdapter` into its own chunk because it imports +`BffClientService` from the main bundle. Same token identity problem. + +**3. Root injector, connector level.** +`{ provide: CartConnector, useExisting: BffCartConnector }` in `AppModule`. +`CartBaseCoreModule` explicitly lists `CartConnector` in its lazy injector +providers. The lazy injector always shadows the root injector. + +### What works + +Override `CartConnector` and `CartEntryConnector` inside the same lazy +injector, with the subclasses **defined inline in the same file** as the +wrapping module — the bundler cannot split them into separate chunks. + +``` +BffCartBaseModule (lazy, registered as CART_BASE_FEATURE) + imports: [CartBaseModule] ← CartBaseCoreModule registers CartConnector + providers: [ + BffCartConnectorImpl, ← same file, same chunk + { provide: CartConnector, useExisting: BffCartConnectorImpl }, + BffCartEntryConnectorImpl, + { provide: CartEntryConnector, useExisting: BffCartEntryConnectorImpl }, + ] +``` + +Angular processes `imports` before `providers`, so `CartBaseCoreModule` +registers first, then `BffCartBaseModule` overrides — last provider wins. + +**Product does not have this problem** because `ProductOccModule` is imported +eagerly in `SpartacusFeaturesModule`, so all product adapter bindings live in +the root injector. A simple root-level `BffProductModule` wins without any +lazy chunk gymnastics. + +--- + +## Running and testing + +### Start + +```bash +# Terminal 1 — BFF (must restart after any change to apps/bff/src/) +OCC_BASE_URL=https://api.cc3ihxtp03-mcpacppoc1-p3-public.model-t.myhybris.cloud \ + npm run dev:bff + +# Terminal 2 — Storefront +npm run start:storefrontapp +``` + +### Demonstrate the PDP performance difference + +1. Open DevTools → Network → throttle to **Slow 3G** +2. In `bff-product.module.ts`, set `USE_AGGREGATION = false`, save +3. Navigate to a product detail page on the `electronics-spa` base site + — observe **3 sequential** `/bff/api/product.*` calls +4. Set `USE_AGGREGATION = true`, save (dev server hot-reloads) +5. Hard-reload the product page — observe **1** `/bff/api/product.getPageData` + call returning in roughly the time of the slowest individual call + +### Confirm cart calls go through BFF + +Filter Network by `/bff/api`. Add product `429430` (Rechargeable Battery Pack) +or `23355` (Tripod) to cart. Observe `cart.create` then `cart.addEntry` — no +direct OCC calls in the Network tab. + +### Reset stale cart state + +```js +Object.keys(localStorage) + .filter(k => k.startsWith('spartacus')) + .forEach(k => localStorage.removeItem(k)); +location.reload(); +``` + +### Standalone BFF demo page + +Navigate to the `/bff-cart` route on the `electronics-spa` base site — this +tests BFF cart procedures directly outside Spartacus's NgRx state. + +--- + +## Improvements borrowed from the spartacus-bff reference + +The `spartacus-bff` workspace contains a production-grade +library implementation of this pattern. Four improvements were back-ported. + +### 1. `normalizeUpstreamError` — proper error semantics + +Maps upstream HTTP status to typed tRPC error codes (`NOT_FOUND`, +`UNAUTHORIZED`, `BAD_REQUEST`) so the storefront can distinguish and handle +them correctly rather than treating every failure as a generic 502. + +**Files:** `apps/bff/src/api/utils/normalize-upstream-error.ts` + +### 2. `isResourceNotFoundError` — graceful 404 handling + +Any 404 from OCC on `cart.load` (not just `anonymous + current`) returns +`undefined`, triggering fresh cart creation. Previously stale GUIDs from +expired sessions caused unrecoverable errors. + +**File:** `apps/bff/src/api/utils/normalize-upstream-error.ts` + +### 3. `commonHeaders` / `defineCartProcedure` — consistent header forwarding + +A wrapper that merges `accept-language` and `authorization` into every +procedure's `meta.headers`. Previously `Accept-Language` was silently dropped +and OCC always responded in its default language. + +**File:** `apps/bff/src/api/routers/cart.ts`, `apps/bff/src/api/routers/product.ts` + +### 4. `CartConverter` / `enrichCart` — server-side data shaping + +`cart.load` and `cart.loadAll` return price-enriched responses with formatted +price strings and server-computed `totalUnitCount`. Price formatting +(`Intl.NumberFormat`) runs once on the server instead of per-browser +per-render. + +**File:** `apps/bff/src/api/utils/cart-converter.ts` + +--- + +## File layout + +``` +apps/bff/ + src/api/routers/ + product.ts — getProduct, getReferences, getReviews, getPageData + cart.ts — 7 cart procedures with error handling + enrichCart + root.ts — registers product + cart routers + src/api/utils/ + normalize-upstream-error.ts — normalizeUpstreamError + isResourceNotFoundError + cart-converter.ts — enrichCart (server-side price formatting) + vivaldi.apis.ts — occ_v2 destination with OAuth2 client credentials + .env — OCC_BASE_URL, OCC_CLIENT_ID, OCC_CLIENT_SECRET + +apps/storefrontapp/src/app/ + bff/ + product/ + bff-product.module.ts — BffProductModule (root-level, no lazy issues) + USE_AGGREGATION toggle for demo + cart/ + bff-cart-base.module.ts — BffCartBaseModule (lazy chunk, inline classes) + bff-auth.link.ts — authorizedOps: token only for logged-in users + bff-mini-cart.component.ts — BFF 🛒 badge in top-right corner + spartacus/features/cart/ + cart-base-feature.module.ts — points CART_BASE_FEATURE at BffCartBaseModule + app.module.ts — imports BffProductModule + app.component.ts/.html/.scss — renders BffMiniCartComponent +``` + +--- + +## Approach 2 — Facade-level override + +An alternative for cart in `bff-active-cart.service.ts` replaces +`ActiveCartFacade` entirely using Angular Signals — no NgRx for cart. + +To activate: import `BffActiveCartModule` in `app.module.ts` and revert +`cart-base-feature.module.ts` to use `CartBaseModule` directly (not +`BffCartBaseModule` — both at once conflict). + +| Aspect | Approach 1 (active) | Approach 2 | +|--------|---------------------|------------| +| Override point | `CartConnector` in lazy injector | `ActiveCartFacade` in root injector | +| NgRx | Kept intact | Eliminated for cart | +| Upgrade safety | High — connector contract stable | Medium — facade contract must hold | +| Recommended for | Migrating existing storefront | Greenfield / performance-critical | + +### Key implementation details + +#### `toObservable()` must be called inside an injection context + +Angular's `toObservable()` calls `inject(DestroyRef)` internally. If called +inside a method at runtime (outside Angular's construction phase) it throws +`NG0203: toObservable() can only be used within an injection context`. + +**Wrong — called inside a method:** +```ts +getActive(): Observable { + return toObservable(this._cart).pipe(...); // ← throws NG0203 at runtime +} +``` + +**Correct — called as field initializers (run during construction):** +```ts +@Injectable() +export class BffActiveCartService implements ActiveCartFacade { + private readonly _cart = signal(undefined); + private readonly _loading = signal(false); + + // Converted once during construction — inside the injection context. + private readonly _cart$ = toObservable(this._cart); + private readonly _loading$ = toObservable(this._loading); + + getActive(): Observable { + return this._cart$.pipe(filter((c): c is Cart => c !== undefined)); + } +} +``` + +#### Anonymous carts use `guid`, not `code` + +OCC returns anonymous carts with a `guid` field as the identifier. The `code` +field is only populated for named carts belonging to authenticated users. Using +`cart.code` alone as the cart ID causes all write operations (`addEntry`, +`removeEntry`, `updateEntry`) to silently bail out for anonymous sessions. + +**Wrong:** +```ts +addEntry(productCode: string, quantity: number): void { + const cartId = this._cart()?.code; // undefined for anonymous carts + if (!cartId) return; // always exits early — no BFF call made + ... +} +``` + +**Correct — prefer `guid`, fall back to `code` for authenticated users:** +```ts +addEntry(productCode: string, quantity: number, pickupStore?: string): void { + const cart = this._cart(); + const cartId = cart?.guid ?? cart?.code; // guid for anonymous, code for auth + if (!cartId) return; + this._loading.set(true); + this.baseSite().then((baseSite) => + this.bff.client.cart.addEntry + .mutate({ baseSite, userId: this._userId(), cartId, productCode, quantity, pickupStore }) + .then(() => this._reload()) + .finally(() => this._loading.set(false)), + ); +} +``` + +#### "Added to Cart" dialog flickers / shows empty — event ordering matters + +`AddedToCartDialogComponent` drives its display from two streams: + +- **`loaded$`** = `activeCartFacade.isStable()` — controls the spinner +- **`entry$`** = `activeCartFacade.getLastEntry(productCode)` — the item to show + +Both are subscribed immediately when `CartAddEntrySuccessEvent` fires. + +If the event fires **before** `_reload()` completes, two bugs occur simultaneously: + +1. `getLastEntry()` returns nothing because the cart still has its pre-add state → empty dialog +2. `_reload()` then sets `_loading = true` → `isStable()` emits `false` → spinner appears on top of the (empty) dialog → flicker + +**Fix:** make `_reload()` return `Promise` and `await` it before dispatching the event. The cart is fully updated and `isStable()` is already `true` when the dialog subscribes: + +```ts +private _reload(): Promise { + this._loading.set(true); + return this.baseSite().then(async (baseSite) => { + try { + const userId = this._userId(); + const existingGuid = this._cart()?.guid ?? this._cart()?.code; + if (existingGuid) { + const cart = await this.bff.client.cart.load.query({ baseSite, userId, cartId: existingGuid }); + this._cart.set(cart as unknown as Cart); + } else { + const created: any = await this.bff.client.cart.create.mutate({ baseSite, userId }); + const guid: string = created?.guid ?? created?.code ?? ''; + if (guid) { + const cart = await this.bff.client.cart.load.query({ baseSite, userId, cartId: guid }); + this._cart.set(cart as unknown as Cart); + } + } + } catch { + this._cart.set(undefined); + } finally { + this._loading.set(false); + } + }); +} + +addEntry(productCode: string, quantity: number, pickupStore?: string): void { + const cart = this._cart(); + const cartId = cart?.guid ?? cart?.code; + if (!cartId) return; + this.baseSite().then((baseSite) => + this.bff.client.cart.addEntry + .mutate({ baseSite, userId: this._userId(), cartId, productCode, quantity, pickupStore }) + .then(async () => { + // Reload first — cart must contain the new entry and isStable() must + // be true before CartAddEntrySuccessEvent fires. + await this._reload(); + const event = new CartAddEntrySuccessEvent(); + event.productCode = productCode; + event.quantity = quantity; + event.cartId = cartId; + event.userId = this._userId(); + this.eventService.dispatch(event, CartAddEntrySuccessEvent); + }) + .catch((err) => { + const event = new CartAddEntryFailEvent(); + event.productCode = productCode; + event.quantity = quantity; + event.cartId = cartId; + event.userId = this._userId(); + event.error = err; + this.eventService.dispatch(event, CartAddEntryFailEvent); + }), + ); +} +``` + +Import `CartAddEntrySuccessEvent`, `CartAddEntryFailEvent` from `@spartacus/cart/base/root` and inject `EventService` from `@spartacus/core`. + + + +#### Cart creation before first load + +OCC does not accept `cartId: 'current'` for anonymous users via direct GUID +lookup. `_reload()` must check whether a GUID is already known and, if not, +create a new cart first: + +```ts +private _reload(): void { + this._loading.set(true); + this.baseSite().then(async (baseSite) => { + try { + const userId = this._userId(); + const existingGuid = this._cart()?.guid ?? this._cart()?.code; + + if (existingGuid) { + // Load existing cart by GUID + const cart = await this.bff.client.cart.load.query({ + baseSite, userId, cartId: existingGuid, + }); + this._cart.set(cart as unknown as Cart); + } else { + // First load: create cart, then load by returned GUID + const created: any = await this.bff.client.cart.create.mutate({ baseSite, userId }); + const guid: string = created?.guid ?? created?.code ?? ''; + if (guid) { + const cart = await this.bff.client.cart.load.query({ + baseSite, userId, cartId: guid, + }); + this._cart.set(cart as unknown as Cart); + } + } + } catch { + this._cart.set(undefined); + } finally { + this._loading.set(false); + } + }); +} +``` diff --git a/docs/spa-bff-reference-implementation.md b/docs/spa-bff-reference-implementation.md new file mode 100644 index 00000000000..d3f7f3761bb --- /dev/null +++ b/docs/spa-bff-reference-implementation.md @@ -0,0 +1,1508 @@ +# Spartacus — BFF Reference Implementation + +This document describes the complete reference implementation for integrating a Vivaldi +BFF (Backend for Frontend) with Spartacus. It covers all files you need to +create or modify on both the **Spartacus storefront** and the **Vivaldi BFF** +sides, and how they work together. + +> **Scope:** This guide covers CSR (Client-Side Rendering) deployment only. SSR support on CCv2 is planned but not yet validated for the initial release. + +## Table of Contents + +- [Getting started from scratch](#getting-started-from-scratch) + - [Step 1: Create the Vivaldi BFF workspace](#step-1-create-the-vivaldi-bff-workspace) + - [Step 2: Create the Spartacus storefront](#step-2-create-the-spartacus-storefront) + - [Step 3: Import the storefront into the Vivaldi workspace](#step-3-import-the-storefront-into-the-vivaldi-workspace) + - [Step 4: Configure storefrontapp as an Nx project](#step-4-configure-storefrontapp-as-an-nx-project) + - [4a. Register the @nx/angular plugin in nx.json](#4a-register-the-nxangular-plugin-in-nxjson) + - [4b. Create apps/storefrontapp/project.json](#4b-create-appsstorefrontappprojectjson) + - [4c. Migrate angular.json to project.json](#4c-migrate-angularjson-to-projectjson-existing-angular-cli-projects-only) + - [4d. Add @repo/bff/* path aliases to the storefrontapp tsconfig.json](#4d-add-repobff-path-aliases-to-the-storefrontapp-tsconfigjson) + - [4e. Fix .angular/cache appearing as untracked files](#4e-fix-angularcache-appearing-as-untracked-files) + - [Step 5: Base Spartacus configuration](#step-5-base-spartacus-configuration) +- [Architecture and URL injection](#architecture-and-url-injection) +- [Spartacus changes](#spartacus-changes) + - [CRITICAL: Remove hardcoded baseUrl](#critical-remove-hardcoded-baseurl-from-spartacus-configuration) + - [CRITICAL: OCC URL must have a CA-signed certificate](#critical-occ-url-must-have-a-valid-ca-signed-certificate-for-bff-use) + - [1. index.html](#1-srcindexhtml) + - [2. bff-base-url.token.ts](#2-srcappbffbff-base-urltokents-new-file) + - [3. bff-error-handling.link.ts](#3-srcappbffbff-error-handlinglinkts-new-file) + - [4. bff-auth.link.ts](#4-srcappbffbff-authlinkts-new-file) + - [5. bff-timeout.link.ts](#5-srcappbffbff-timeoutlinkts-new-file) + - [6. bff-client.service.ts](#6-srcappbffbff-clientservicets-new-file) + - [8. proxy.conf.js](#8-proxyconfjs-new-file-project-root) + - [9. project.json](#9-projectjson-modify-storefrontapp) + - [10. package.json scripts](#10-packagejson-scripts) + - [11. .env-cmdrc](#11-env-cmdrc-create-or-modify-project-root) + - [12. Example: custom BFF procedure](#12-example-custom-bff-procedure-srcappbffexamplessay-hellocomponentts) + - [13. Example: OCC call via BFF](#13-example-occ-call-via-bff-srcappbffexamplesocc-base-sitescomponentts) + - [14. bff-example.providers.ts](#14-appsrcbffexamplesbff-exampleprovidersts-new-file) +- [Known issue — E401 on CCv2 build agents](#known-issue--npm-error-code-e401-on-ccv2-build-agents) +- [Vivaldi BFF changes](#vivaldi-bff-changes) + - [15. env.d.ts](#15-appsbffenvdts-modify) + - [16. vivaldi.apis.ts](#16-appsbffvivaldiapists-modify) + - [17. destinations.ts](#17-packagescontractsbffdestinationsts-modify) + - [18. context.ts](#18-appsbffsrcapicontextts-modify) + - [19. occ.ts router](#19-appsbffsrcapiroutersoccts-new-file) + - [20. root.ts](#20-appsbffsrcapiroutersrootts-modify) + - [21. .env](#21-appsbffenv-local-dev-only) +- [File overview](#file-overview) +- [Testing locally](#testing-locally) +- [Deployment notes](#deployment-notes) +- [References](#references) + +--- + +## Getting started from scratch + +These steps describe how to create a fresh Nx monorepo that contains both a Vivaldi BFF +and a Spartacus Angular storefront, starting from nothing. Follow them in order +before applying the BFF integration changes described in the rest of this document. + +### Prerequisites + +> **Version pinning note:** This guide pins exact versions of Spartacus and Vivaldi +> packages because it documents what was tested for the initial release. In future +> iterations the exact versions will be replaced with `@latest` so customers always +> start from the newest available release without needing doc updates on every version +> bump. + +| Tool | Required version | Notes | +|---|---|---| +| Node.js | 20 LTS or 22 LTS | Earlier versions are not tested | +| Angular CLI | 21.2.x | Do **not** use 21.1.x — it has peer-dep conflicts with Spartacus 221121.13.1 | +| Spartacus schematics | 221121.13.1 | — | +| `@vivaldi/nx` generator | 0.25.0 | — | + +#### SAP npm registry access + +Both `@vivaldi/*` and `@spartacus/*` packages are hosted on SAP's internal npm registry (Artifactory), not the public npm registry. Every `npm install` in this guide requires a valid `SAP_RBSCTOKEN` in your shell environment: + +```bash +export SAP_RBSCTOKEN= +``` + +**Before running any command in this guide**, add both registry scopes to your **user-level `~/.npmrc`**. This is required because `npx @vivaldi/nx` in Step 1 must resolve `@vivaldi/nx` from the SAP registry before the workspace `.npmrc` exists — there is a chicken-and-egg problem if you rely solely on a workspace-level file. + +``` +@vivaldi:registry=https://73555000100900008602.npmsrv.base.repositories.cloud.sap/ +//73555000100900008602.npmsrv.base.repositories.cloud.sap/:_auth=${SAP_RBSCTOKEN} +//73555000100900008602.npmsrv.base.repositories.cloud.sap/:always-auth=true +@spartacus:registry=https://73554900100900004337.npmsrv.base.repositories.cloud.sap/ +//73554900100900004337.npmsrv.base.repositories.cloud.sap/:_auth=${SAP_RBSCTOKEN} +//73554900100900004337.npmsrv.base.repositories.cloud.sap/:always-auth=true +``` + +The `@vivaldi/nx` scaffolder also creates a `.npmrc` in the workspace root with the `@vivaldi` scope. The `@spartacus` scope is not added by the scaffolder — it must come from your user-level `~/.npmrc` as shown above. + +> **Note:** The E401 lockfile-regeneration workaround in Step 4 (`NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ npm install`) applies **only** to regenerating `package-lock.json` against the public registry to avoid Artifactory-resolved URLs in the lockfile. Use it only for that specific step — do not use it for the main `npm install` commands, as `@vivaldi/*` and `@spartacus/*` packages are not on the public registry. + +--- + +### Step 1: Create the Vivaldi BFF workspace + +Use the Vivaldi Nx generator to scaffold a workspace. + +```bash +npx @vivaldi/nx@0.25.0 --no-interactive --workspace=my-vivaldi-workspace --nxCloud=skip +cd my-vivaldi-workspace +``` + +This creates an Nx monorepo with an `apps/bff` application pre-configured for Vivaldi. + +> **Required:** the workspace must be a git repository before Step 3. The generator +> prints "Initializing git repository..." but does not complete the commit. Run: +> ```bash +> git init && git add -A && git commit -m "chore: initial vivaldi workspace" +> ``` + +Install `nx` and `@nx/angular` aligned to the same version as the other `@nx/*` packages +already installed by the scaffolder. The scaffolder pins `nx` at a lower version than +the `@nx/*` packages it installs — leaving them mismatched causes `nx import` to pick +the wrong `@nx/vitest` version and fail with an ERESOLVE error. Check the installed +`@nx/vite` version and use that for both: + +```bash +node -e "console.log(require('./node_modules/@nx/vite/package.json').version)" + +npm install --save-dev nx@22.7.7 @nx/angular@22.7.7 +``` + +The scaffolded `tsconfig.base.json` uses `"baseUrl": "."` which TypeScript 5.9+ treats +as deprecated, causing `nx run bff:typecheck` to fail with `TS5101`. Add +`"ignoreDeprecations": "6.0"` to `tsconfig.base.json` to silence it: + +```json +"baseUrl": ".", +"ignoreDeprecations": "6.0" +``` + +> **Required before Step 3:** the workspace must be in a clean git state before +> running `nx import`. Two things can make it dirty after this point: +> +> 1. The `npm install` above modifies `package-lock.json` +> 2. Running any `nx` workspace command for the first time prompts "Share usage +> data with the Nx team?" — answering either way writes `"analytics": false/true` +> to `nx.json`. Note: `nx --version` does **not** trigger this prompt — use a +> real workspace command such as `nx show projects`. +> +> Trigger the analytics prompt now (before Step 3), then commit everything: +> ```bash +> npx nx show projects # triggers the analytics prompt if not yet answered +> git add -A && git commit -m "chore: align nx and @nx/angular versions" +> ``` + +--- + +### Step 2: Create the Spartacus storefront + +> **Skip this step** if you already have an existing Angular application. + +> **Important:** create the storefront **outside** `my-vivaldi-workspace`. If you run +> `ng new` from inside the Vivaldi workspace, the storefront lands as a subfolder of +> that git repository — making the workspace dirty and causing `nx import` to refuse +> with "You have uncommitted changes". The storefront must be a sibling directory, +> not a child of `my-vivaldi-workspace`. + +**Prerequisite:** install the Angular CLI globally. + +```bash +npm install -g @angular/cli@21 +``` + +Navigate out of the Vivaldi workspace before creating the storefront: + +```bash +cd .. +ng new my-storefront-app --style=scss --zoneless=false \ + --file-name-style-guide=2016 +cd my-storefront-app +``` + +Commit immediately after `ng new` — before adding Spartacus. If the schematics fail +or produce only a partial result, this gives you a clean rollback point without having +to recreate the Angular app from scratch: + +```bash +git init && git add -A && git commit -m "chore: initial Angular app" +``` + +Add the Spartacus schematics: + +```bash +ng add @spartacus/schematics@221121.13.1 --skip-confirmation +``` + +When the feature selection prompt appears, use **Space** to toggle features and **Enter** +to confirm. Accept the defaults or customise the selection to match your project's needs. + +Commit the Spartacus changes: + +```bash +git add -A && git commit -m "chore: add Spartacus schematics" +``` + +--- + +### Step 3: Import the storefront into the Vivaldi workspace + +Return to the Vivaldi workspace root and run the import: + +```bash +cd ../my-vivaldi-workspace +npx nx import ../my-storefront-app apps/storefrontapp --ref=main +``` + +The command asks two questions interactively — press **Enter** at both (import the +entire repository, do not update package.json scripts). It then shows a plugin +selection prompt: + +``` +? Which plugins would you like to add? Press to select and to submit. +``` + +Press **Space** to deselect `@nx/vitest` (and any other pre-selected plugin), then +**Enter** to submit with nothing selected. `@nx/vitest` cannot be installed cleanly +due to a version split in the workspace (see Step 1), and `@nx/angular` is registered +manually in Step 4 — no plugins are needed here. + +> **Note:** `--plugins=skip` is documented by Nx but only takes effect in AI agent +> mode (`isAiAgent() === true`). It is silently ignored in a regular interactive +> terminal session — the interactive prompt always appears regardless. + +--- + +### Step 4: Configure storefrontapp as an Nx project + +After importing, manual wiring is needed to make Nx aware of the Angular targets. + +> **Note:** the `project.json` paths below (`apps/storefrontapp/src/...`) assume the +> storefront was imported as a plain Angular CLI project. Do not run `nx init --integrated` +> on the storefront before importing — it nests the source at the wrong depth and breaks +> all paths in the template below. + +#### 4a. Register the `@nx/angular` plugin in `nx.json` + +Add to `nx.json` → `plugins` array: + +```json +{ + "plugin": "@nx/angular/plugin", + "options": { + "buildTargetName": "build", + "serveTargetName": "serve", + "testTargetName": "test", + "extractI18nTargetName": "extract-i18n", + "serveStaticTargetName": "serve-static" + } +} +``` + +#### 4b. Create `apps/storefrontapp/project.json` + +```json +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "storefrontapp", + "projectType": "application", + "sourceRoot": "apps/storefrontapp/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular/build:application", + "options": { + "outputPath": "dist/apps/storefrontapp", + "browser": "apps/storefrontapp/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/storefrontapp/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { "glob": "**/*", "input": "apps/storefrontapp/public" }, + { + "glob": "**/*", + "input": "node_modules/@spartacus/smartedit/assets", + "output": "assets/" + } + ], + "styles": [ + "apps/storefrontapp/src/styles.scss", + "apps/storefrontapp/src/styles/spartacus/user.scss", + "apps/storefrontapp/src/styles/spartacus/cart.scss", + "apps/storefrontapp/src/styles/spartacus/order.scss", + "apps/storefrontapp/src/styles/spartacus/checkout.scss", + "apps/storefrontapp/src/styles/spartacus/storefinder.scss", + "apps/storefrontapp/src/styles/spartacus/asm.scss", + "apps/storefrontapp/src/styles/spartacus/product.scss" + ], + "stylePreprocessorOptions": { + "includePaths": ["node_modules/"], + "sass": { "silenceDeprecations": ["import"] } + } + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kB", "maximumError": "3.5mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "options": { + "buildTarget": "storefrontapp:build", + "proxyConfig": "apps/storefrontapp/proxy.conf.js" + }, + "configurations": { + "production": { "buildTarget": "storefrontapp:build:production" }, + "development": { "buildTarget": "storefrontapp:build:development" } + }, + "defaultConfiguration": "development" + }, + "test": { + "executor": "@angular/build:unit-test", + "options": { + "tsConfig": "apps/storefrontapp/tsconfig.spec.json" + }, + "configurations": { + "test": { + "stylePreprocessorOptions": { "includePaths": ["node_modules/"] } + } + } + } + } +} +``` + +> **Important:** make sure `outputPath` is `dist/apps/storefrontapp` (not +> `dist/apps/my-storefront-app` or whatever the Angular CLI defaulted to). + +> **Note:** After `nx import`, `apps/storefrontapp/angular.json` is present alongside +> this `project.json`. Nx uses `project.json` when the `@nx/angular` plugin is registered +> (step 4a), but keeping both files can cause confusing target merges. Once +> `nx run storefrontapp:build` succeeds, delete `apps/storefrontapp/angular.json`. This +> applies to the fresh Spartacus path (Steps 1–2) as well as existing projects. + +> **Note:** This template already includes `proxyConfig` in `serve`. If you are +> following the fresh Spartacus path (Steps 1–2), step 9 (`project.json` modify) in +> the Spartacus changes section is already covered by this template — you do not need +> to add `proxyConfig` again separately. + +#### 4c. Migrate `angular.json` to `project.json` (existing Angular CLI projects only) + +> **Skip this step** if you followed Step 2 and created a fresh storefront — you already +> have `angular.json` from `ng new` and the `project.json` above replaces it entirely. +> This step is for teams who bring in an **existing** Angular CLI project whose +> `angular.json` was not yet converted to the Nx-native format. + +After `nx import`, the storefront still has an `angular.json` in its subdirectory +(`apps/storefrontapp/angular.json`). Nx can read targets from `angular.json` via the +`@nx/angular` plugin registered in step 4a, but the file uses Angular CLI conventions +that differ from what `project.json` expects in a Vivaldi workspace: + +| `angular.json` | `project.json` | +|---|---| +| `architect` object | `targets` object | +| `builder` key | `executor` key | +| All paths relative to the app directory (`src/main.ts`) | All paths relative to workspace root (`apps/storefrontapp/src/main.ts`) | +| No `outputPath` — defaults to project `root` | Explicit `outputPath: "dist/apps/storefrontapp"` required | +| `serve` has no top-level `options` block | `serve` carries `proxyConfig` in top-level `options` | +| `test` options inline under `options` | `test` delegates to `buildTarget` for shared build config | + +**Automated option — `nx init`:** + +If the storefront is still a standalone Angular CLI workspace (i.e. you have not yet +run `nx import`), Nx provides an automated path: + +```bash +# Inside the standalone storefront directory (before nx import) +npx nx@latest init +``` + +After `nx init` completes, commit the newly added `nx.json` and `project.json` before +running `nx import` — the workspace must be in a clean git state: + +```bash +git add nx.json project.json +git commit -m "chore: run nx init" +``` + +This command installs Nx, creates `nx.json`, and converts `angular.json` into a +`project.json` using the modern `@angular/build:application` executor — no +`architect`/`builder` keys to rename manually. + +> **What `nx init` does NOT do:** it does not prefix paths to the workspace root. +> After `nx import`, all paths in the generated `project.json` are still relative +> to the app directory (`src/main.ts`, `tsconfig.app.json`, etc.), not the workspace +> root (`apps/storefrontapp/src/main.ts`). `nx import` itself warns about this: +> *"Source directory (.) differs from destination (apps/storefrontapp) — update +> relative paths in configuration files."* Building immediately fails until the +> paths are fixed. +> +> After the import you still need to apply steps 2–8 from the manual migration below: +> prefix all paths with `apps/storefrontapp/`, update `outputPath`, add `proxyConfig`, +> fix `buildTarget` references, rename the project to `storefrontapp` +> (the `name` field in `project.json` keeps the original app name), and ensure +> dependencies are merged into the workspace root `package.json` (step 8). +> +> **If `nx run storefrontapp:build` fails after completing the migration steps**, run +> `nx reset` to clear any stale cached configuration from before the import, then retry. +> Nx can cache the old project graph and fail even after paths are correctly updated. + +> **Limitation:** `nx init` is designed for standalone Angular CLI workspaces. Once +> the storefront has been imported into the Vivaldi monorepo via `nx import` (Step 3), +> running `nx init` inside `apps/storefrontapp/` will not produce the correct result — +> it would re-scaffold an Nx workspace inside a sub-directory instead of registering +> the project in the existing workspace. For projects that are **already inside the +> monorepo**, use the manual steps below. + +**Manual migration:** + +To convert, apply the following transformations to `apps/storefrontapp/angular.json` +and save the result as `apps/storefrontapp/project.json`: + +1. **Rename top-level keys.** Inside the project entry, replace `architect` with `targets`. + Inside each target, replace `builder` with `executor`. + +2. **Prefix all paths** with `apps/storefrontapp/`. Every file reference that was + relative to the app directory (e.g. `src/main.ts`, `src/styles.scss`, `public`) + must become workspace-root-relative (e.g. `apps/storefrontapp/src/main.ts`). + The `node_modules/` input path in the SmartEdit asset glob is the one exception — + it should stay as `node_modules/@spartacus/smartedit/assets` (no prefix) because + it resolves from the workspace root already. + +3. **Add `outputPath`** to the `build` target's `options`: + ```json + "outputPath": "dist/apps/storefrontapp" + ``` + +4. **Add `proxyConfig`** to the `serve` target (a new top-level `options` block): + ```json + "options": { + "proxyConfig": "apps/storefrontapp/proxy.conf.js" + } + ``` + +5. **Update `buildTarget` references** in `serve` configurations. The Angular CLI + `angular.json` names them after the original project name + (e.g. `ccv2-spa-doc-test4-storefront:build:development`). Change all + references to use the Nx project name `storefrontapp`: + ```json + "production": { "buildTarget": "storefrontapp:build:production" }, + "development": { "buildTarget": "storefrontapp:build:development" } + ``` + +6. **Simplify the `test` target.** The `angular.json` `test` target inlines all style + options directly. Replace it with the leaner form that delegates to the `build` + target's `test` configuration (which carries the style preprocessor options): + ```json + "test": { + "executor": "@angular/build:unit-test", + "options": { + "buildTarget": "storefrontapp:build:test", + "tsConfig": "apps/storefrontapp/tsconfig.spec.json" + } + } + ``` + And add a `test` configuration to the `build` target's `configurations` block: + ```json + "test": { + "optimization": false, + "extractLicenses": false, + "sourceMap": false, + "stylePreprocessorOptions": { + "includePaths": ["node_modules/"], + "sass": { "silenceDeprecations": ["import"] } + }, + } + ``` + +7. **Add Nx project metadata** at the top level: + ```json + { + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "storefrontapp", + "projectType": "application", + "sourceRoot": "apps/storefrontapp/src", + "tags": [], + ... + } + ``` + +8. **Delete `angular.json`.** Once `project.json` is in place, ensure + `apps/storefrontapp/package.json` dependencies have been merged into the workspace + root `package.json` and `npm install` has been run at the workspace root — the build + requires all packages to be resolved from the workspace root `node_modules`. Then + run `nx run storefrontapp:build` to verify, and remove `apps/storefrontapp/angular.json`. + Keeping both files causes Nx to merge targets from both, which can produce + confusing duplicates. + +> **Tip:** The `nx migrate` command does not automate this conversion — it is a +> one-time manual step per project. After the conversion, commit both the new +> `project.json` and the deletion of `angular.json` together so the changeset is +> atomic and easy to revert. + +#### 4d. Add `@repo/bff/*` path aliases to the storefrontapp `tsconfig.json` + +The storefrontapp was originally a standalone Angular CLI project whose `tsconfig.json` +does not inherit from the Vivaldi workspace's `tsconfig.base.json`. The BFF client files +(`bff-client.service.ts` etc.) import `@repo/bff/clients` which is only defined in +`tsconfig.base.json`. Add the relevant paths directly to `apps/storefrontapp/tsconfig.json` +under `compilerOptions`: + +```json +"baseUrl": ".", +"paths": { + "@repo/bff/clients": ["../../packages/clients/bff/index.ts"], + "@repo/bff/clients/*": ["../../packages/clients/bff/*.ts", "../../packages/clients/bff/*/index.ts"], + "@repo/bff/contracts": ["../../packages/contracts/bff/index.ts"], + "@repo/bff/router": ["../../apps/bff/src/api/routers/root.ts"], + "@repo/bff/trpc": ["../../apps/bff/src/api/trpc.ts"] +} +``` + +> **Note:** Do not add `"extends": "../../tsconfig.base.json"` to the storefrontapp tsconfig. +> The Vivaldi workspace `tsconfig.base.json` uses different compiler settings (e.g. `module: esnext`, +> `target: es2015`) that conflict with Angular 21's required `module: preserve` and `target: ES2022` +> settings. Adding the paths manually avoids this conflict. + +**Merge `apps/storefrontapp/package.json` into the workspace root:** + +1. Move all `dependencies` and `devDependencies` from `apps/storefrontapp/package.json` + into the root `package.json`, resolving any version conflicts. +2. Delete `apps/storefrontapp/package.json` and `apps/storefrontapp/package-lock.json`. + +3. Run `npm install` at the workspace root. +4. Verify: `nx run storefrontapp:build` completes successfully. + +> **Note:** `nx run storefrontapp:serve` requires `proxy.conf.js` which is created in +> step 8. Use `build` for the verification at this stage. + +#### 4e. Fix `.angular/cache` appearing as untracked files + +After `nx import`, `apps/storefrontapp/.gitignore` contains `/.angular/cache` — but +that path is relative to `apps/storefrontapp/`, while Angular actually writes its build +cache to the **workspace root** `.angular/cache/`. The entry has no effect, so the +entire Angular cache appears as untracked files in `git status` after every build. + +Add `.angular/cache` to the **workspace root** `.gitignore`: + +``` +.angular/cache +``` + +--- + +### Step 5: Base Spartacus configuration + +After the import, configure the site context in +`apps/storefrontapp/src/app/spartacus/spartacus-configuration.module.ts`. +The schematics generate a `provideConfig({ context: {} })` block — +you have two options: + +**Option A — Automatic site context (recommended for existing customers)** + +Leave the `context` block empty. Spartacus will make an initial call to the +`/basesites` OCC endpoint, compare the current URL against the URL patterns defined +in the CMS, and determine the active base site, languages, and currencies automatically. +No code change is needed: + +```ts +provideConfig({ + context: {}, +}), +``` + +See [Automatic Multi-Site Configuration](https://help.sap.com/docs/SAP_COMMERCE_COMPOSABLE_STOREFRONT/c9d3b569e57f4db19d4e62a69609f3f0/5b91ded34aaf4a0a91c3e19c7601e4b2.html) +for details on URL pattern matching and caching strategies (SSR, PWA service worker). + +**Option B — Static site context** + +Explicitly define the base sites, languages, and currencies. Use this if you want +deterministic bootstrapping without the initial `/basesites` call, or if your URL +patterns require it. Replace the example values with your own: + +```ts +provideConfig({ + context: { + urlParameters: ['baseSite', 'language', 'currency'], + baseSite: ['electronics-spa', 'apparel-uk-spa'], + currency: ['USD', 'GBP'], + }, +}), +``` + +Do not add a second `SiteContextConfig` block — update the one that already exists. + +--- + +## Architecture and URL injection + +CCv2 injects backend URLs into `index.html` at deploy time by replacing placeholder +strings with real values configured on the environment variable page. + +The `BFF_BASE_URL` injection token reads the substituted value from the meta tag at +Angular bootstrap time — before any component renders. `BffClientService` then uses +this URL to construct a fully typed tRPC client backed by `RootRouter`, so every call +to a BFF procedure is type-checked at compile time against the actual procedure +signatures. Auth is forwarded via a tRPC link that reads the Spartacus Bearer token +from `AuthStorageService` and injects it as an `Authorization` header before each call. + +In local development, the Angular dev-server proxy forwards `/bff/*` to the real BFF +so the browser never makes a cross-origin call. + +--- + +## Spartacus changes + +**Prerequisites:** install `@vivaldi/angular` before applying the changes below: + +```bash +npm install @vivaldi/angular@0.25.0 +``` + +### 1. `src/index.html` + +Add the `bff-base-url` meta tag inside ``. CCv2 replaces the placeholders +at deploy time. + +> **Note:** The Spartacus schematics already generate +> ``. +> Replace the hardcoded value with the placeholder and add the `media-backend-base-url` +> and `bff-base-url` tags alongside it: + +```html + + + +``` + +| Placeholder | Environment variable | Set by | +|---|---|---| +| `OCC_BACKEND_BASE_URL_VALUE` | `OCC_BASE_URL` | User via VariableSet | +| `MEDIA_BACKEND_BASE_URL_VALUE` | `MEDIA_BASE_URL` | User via VariableSet | +| `BFF_BASE_URL_VALUE` | `BFF_BASE_URL` | Platform auto-injects on "Connect to BFF" (value: `/bff/api`) | + +> **Note:** If no BFF is connected, `BFF_BASE_URL_VALUE` is left unreplaced. +> The `BFF_BASE_URL` token treats the placeholder as "not configured" and falls +> back to `/bff/api` — BFF calls will fail gracefully rather than silently. + +--- + +### CRITICAL: Remove hardcoded `baseUrl` from Spartacus configuration + +`provideConfig()` takes precedence over meta tag factories. If your +`spartacus-configuration.module.ts` contains a hardcoded `baseUrl`, the meta tag +is ignored and CCv2 URL injection will not work. + +**Remove this:** +```ts +provideConfig({ + backend: { + occ: { + baseUrl: 'https://hardcoded-url.example.com', // ← remove this + }, + }, +}), +``` + +With this removed, Spartacus reads `` which CCv2 +replaces at deploy time. + +--- + +### CRITICAL: OCC URL must have a valid CA-signed certificate for BFF use + +The BFF runs as a Node.js server on CCv2. Node.js is strict about TLS certificate +verification and will **reject self-signed certificates** (e.g. raw IP addresses like +`https://40.x.x.x:9002`). + +- The **browser** works with self-signed certs because users can manually accept the + security exception +- **Node.js** (the BFF container) has no such mechanism — it rejects self-signed certs + by default in production +- Vivaldi builds the HTTPS agent as `new Agent({ rejectUnauthorized: !vivaldi.env.isDev })` + — in development mode, outgoing requests with certificates not authorized by known + authorities are allowed (see docs: [Environment variables](https://help.sap.com/docs/CC_CEE/098d0a4925094840a656c02370c78e30/854a20159b0e401fad32c13e368b26d2.html?locale=en-US&state=PRODUCTION&version=SHIP&q=environment%20variable#development-environment)); + on CCv2 `vivaldi.env.isDev=false` so self-signed certs are rejected + +**Fix:** The `OCC_BASE_URL` used by the BFF must point to an OCC hostname with a +CA-signed certificate (e.g. `https://api.xxx.model-t.myhybris.cloud`), not a raw IP. + +--- + +### 2. `src/app/bff/bff-base-url.token.ts` *(new file)* + +Reads the `bff-base-url` meta tag at Angular bootstrap time. Falls back to `/bff/api` +for local development (handled by the dev-server proxy). CCv2 injects the full tRPC +endpoint URL as `BFF_BASE_URL_VALUE` (e.g. `/bff/api`) — the token uses it directly. + +```ts +import { InjectionToken, inject } from '@angular/core'; +import { Meta } from '@angular/platform-browser'; + +// Inlined until BFF meta tag constants are released in @spartacus/core (CXSPA-13587). +const BFF_BASE_URL_META_TAG_NAME = 'bff-base-url'; +const BFF_BASE_URL_META_TAG_PLACEHOLDER = 'BFF_BASE_URL_VALUE'; + +export const BFF_BASE_URL = new InjectionToken('BFF_BASE_URL', { + providedIn: 'root', + factory: () => { + const meta = inject(Meta); + const tag = meta.getTag(`name="${BFF_BASE_URL_META_TAG_NAME}"`); + const content = tag?.content ?? ''; + return content && content !== BFF_BASE_URL_META_TAG_PLACEHOLDER + ? content + : '/bff/api'; + }, +}); +``` + +--- + +### 3. `src/app/bff/bff-error-handling.link.ts` *(new file)* + +Forwards BFF procedure errors to Angular's global `ErrorHandler`. In the browser it only fires in dev mode to avoid leaking potentially confidential information to the console. + +Place this as the **first** link in the array so it can observe errors from all +subsequent links. + +```ts +import { isPlatformBrowser } from '@angular/common'; +import { ErrorHandler, inject, isDevMode, PLATFORM_ID } from '@angular/core'; +import { LoggerService } from '@spartacus/core'; +import type { TRPCLink } from '@trpc/client'; +import type { AnyTRPCRouter } from '@trpc/server'; +import { tap } from '@trpc/server/observable'; + +export class OutboundHttpError extends Error { + constructor(cause: unknown) { + super('Outbound HTTP Error', { cause }); + } +} + +export const bffErrorHandlingLink: TRPCLink = () => { + const errorHandler = inject(ErrorHandler); + const platformId = inject(PLATFORM_ID); + const logger = inject(LoggerService); + + return ({ next, op }) => { + try { + return next(op).pipe( + tap({ + error: (error: unknown) => { + if (!isPlatformBrowser(platformId) || isDevMode()) { + errorHandler.handleError(new OutboundHttpError(error)); + } + }, + }), + ); + } catch (error) { + logger.error(op.path, error); + if (!isPlatformBrowser(platformId) || isDevMode()) { + errorHandler.handleError(new OutboundHttpError(error)); + } + throw error; + } + }; +}; +``` + +--- + +### 4. `src/app/bff/bff-auth.link.ts` *(new file)* + +Injects the Spartacus user's Bearer token into every BFF call using the Observable- +based Spartacus auth APIs: + +- `AuthService.isUserLoggedIn()` — skips token injection for anonymous sessions + entirely, so the BFF calls OCC anonymously without an `Authorization` header. +- `AuthHttpHeaderService.getStableToken()` — waits for any in-progress token refresh + to complete before reading the token, preventing 401s caused by attaching a + mid-refresh expired token. +- `Injector` lazy injection — breaks the circular dependency that `AuthService` + creates with Angular's DI graph at module initialisation time. + +This link must be placed **before** `createTerminationLink`. Vivaldi's termination +link intentionally omits `headers` and `fetch` from its options, so auth must be +injected at the link layer via `OperationHeaders`. + +```ts +import { inject, Injector } from '@angular/core'; +import { AuthHttpHeaderService, AuthService, AuthToken } from '@spartacus/core'; +import type { Operation, TRPCLink } from '@trpc/client'; +import type { AnyTRPCRouter } from '@trpc/server'; +import { fromRxObservable, toRxObservable } from '@vivaldi/angular/utils'; +import { OperationHeaders } from '@vivaldi/trpc/universal'; +import { of } from 'rxjs'; +import { first, switchMap } from 'rxjs/operators'; + +export const bffAuthLink: TRPCLink = () => { + const injector = inject(Injector); + + return ({ next, op }) => { + const authService = injector.get(AuthService); + const authHeaderService = injector.get(AuthHttpHeaderService); + + return fromRxObservable( + authService.isUserLoggedIn().pipe( + switchMap((isLoggedIn) => + isLoggedIn ? authHeaderService.getStableToken() : of(undefined), + ), + switchMap((token) => + toRxObservable( + token ? next(createAuthHeader(op, token)) : next(op), + ), + ), + first(), + ), + ); + }; +}; + +function createAuthHeader(op: Operation, token: AuthToken) { + const headers = new OperationHeaders(op); + const tokenType = token.token_type || 'Bearer'; + const accessToken = token.access_token; + + if (!accessToken || typeof accessToken !== 'string') { + return op; + } + + // Strip newlines — Node.js rejects header values containing \r or \n with + // "Invalid character in header content". OAuth tokens can contain newline + // characters from base64 padding or copy-paste artifacts. + const sanitizedToken = accessToken.replace(/[\r\n]/g, ''); + + return headers.append('Authorization', `${tokenType} ${sanitizedToken}`); +} +``` + +--- + +### 5. `src/app/bff/bff-timeout.link.ts` *(new file)* + +Aborts BFF calls that exceed a platform-specific timeout: + +- **Browser**: no timeout by default. The browser's own network stack handles + stalled requests. +- **Dev mode**: a 20-second timeout applies in the browser so issues surface during + development. + +Place this as the **last** link before `createTerminationLink` so the `AbortController` +signal reaches the fetch. + +```ts +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; +import { LoggerService } from '@spartacus/core'; +import type { TRPCLink } from '@trpc/client'; +import type { AnyTRPCRouter } from '@trpc/server'; +import { fromRxObservable, toRxObservable } from '@vivaldi/angular/utils'; +import { timeout, catchError, TimeoutError } from 'rxjs'; + +const DEFAULT_TIMEOUT_MS = 20_000; + +export const bffTimeoutLink: TRPCLink = () => { + const platformId = inject(PLATFORM_ID); + const logger = inject(LoggerService); + + return ({ next, op }) => { + const isBrowser = isPlatformBrowser(platformId); + const timeoutMs = isBrowser ? undefined : DEFAULT_TIMEOUT_MS; + + if (!timeoutMs && !isDevMode()) { + return next(op); + } + + const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; + const abortController = new AbortController(); + op.signal = abortController.signal; + + return fromRxObservable( + toRxObservable(next(op)).pipe( + timeout(effectiveTimeout), + catchError((error) => { + if (error instanceof TimeoutError) { + abortController.abort(); + const message = `BFF procedure "${op.path}" timed out after ${effectiveTimeout}ms.`; + logger.warn(message); + throw new Error(message, { cause: error }); + } + throw error; + }), + ), + ); + }; +}; +``` + +--- + +### 6. `src/app/bff/bff-client.service.ts` *(new file)* + +Typed tRPC client for the BFF. Wires all four links in the correct order and binds +the client to `RootRouter` from `@repo/bff/clients` (type-only import — never +included in the browser bundle). + +Every procedure call is fully type-checked at compile time. TypeScript infers input +and return types directly from the BFF router definition — no manual generic +annotations needed. + +**Link order matters:** + +| Position | Link | Purpose | +|----------|------------------------|------------------------------------------------------| +| 1st | `bffErrorHandlingLink` | Observes all errors; forwards to `ErrorHandler` | +| 2nd | `bffAuthLink` | Injects `Authorization` header when user is logged in | +| 3rd | `bffTimeoutLink` | Aborts calls that exceed the timeout (dev mode only) | +| 4th | `createTerminationLink` | Vivaldi's HTTP link (superjson, OTEL, error envelope) | + +```ts +import { Injectable, inject } from '@angular/core'; +import { createTRPCClient, createTerminationLink } from '@vivaldi/trpc/client'; +import type { RootRouter } from '@repo/bff/clients'; +import { BFF_BASE_URL } from './bff-base-url.token'; +import { bffErrorHandlingLink } from './bff-error-handling.link'; +import { bffAuthLink } from './bff-auth.link'; +import { bffTimeoutLink } from './bff-timeout.link'; + +@Injectable({ providedIn: 'root' }) +export class BffClientService { + readonly client; + + constructor() { + const bffBaseUrl = inject(BFF_BASE_URL); + + this.client = createTRPCClient({ + links: [ + bffErrorHandlingLink, + bffAuthLink, + bffTimeoutLink, + createTerminationLink({ url: bffBaseUrl }), + ], + }); + } +} +``` + +**Usage:** +```ts +// Return type inferred from RootRouter — no generic needed +const res = await this.bff.client.sample.sayHello.query({ name: 'Spartacus' }); +console.log(res.message); // string + +// TypeScript errors if input or property is wrong: +const bad = await this.bff.client.sample.sayHello.query({ name: 123 }); // ← compile error +``` + +--- + +### 8. `proxy.conf.js` *(new file, project root)* + +Reads `CX_BFF_BASE_URL` at dev-server startup and sets the proxy target dynamically. +The browser always calls `/bff/api` (same origin — no CORS). + +In `@vivaldi` 0.25.0 `vivaldi dev bff` runs as an HTTPS server (self-signed cert) +on port 8482 and mounts tRPC at `/bff/api`. The proxy forwards `/bff` directly to the +BFF — no path rewriting needed since the paths already match. + +```js +const bffBaseUrl = + process.env['CX_BFF_BASE_URL'] || 'https://localhost:8482/bff/api'; +const bffTarget = new URL(bffBaseUrl).origin; + +module.exports = { + '/bff': { + target: bffTarget, + secure: false, + changeOrigin: true, + ws: false, + logLevel: 'info', + }, +}; +``` + +--- + +### 9. `project.json` *(modify, storefrontapp)* + +> **Note:** If you followed Step 2 (fresh Spartacus app) and used the `project.json` +> template from step 4b, `proxyConfig` is already included — skip this step. +> This step is for existing Angular CLI projects that created `project.json` manually +> via the 4c migration. + +Add `proxyConfig` to the `serve` target's `options`. The executor for a standard Angular +app is `@angular/build:dev-server`: + +```json +"serve": { + "executor": "@angular/build:dev-server", + "options": { + "buildTarget": "storefrontapp:build", + "proxyConfig": "apps/storefrontapp/proxy.conf.js" + } +} +``` + +--- + +### 10. `package.json` scripts + +Add the following convenience scripts to the root `package.json`. They give all team +members consistent commands regardless of which nx target names are used internally: + +```json +{ + "scripts": { + "build:bff": "vivaldi build bff", + "dev:bff": "vivaldi dev bff", + "start:storefrontapp": "nx serve storefrontapp", + "build:storefrontapp": "nx build storefrontapp", + "test:storefrontapp": "nx test storefrontapp" + } +} +``` + +| Script | What it does | +|------------------------------------|------------------------------------------------------------------| +| `npm run build:bff` | Builds the BFF into `dist/apps/bff/vivaldi.mjs` | +| `npm run dev:bff` | Starts the BFF dev server via `vivaldi dev bff` | +| `npm run start:storefrontapp` | Starts the Angular dev server on port 4200 | +| `npm run build:storefrontapp` | Production build of the storefront | +| `npm run test:storefrontapp` | Runs unit tests for the storefront | + +> **Note:** The Hosting Portal builds each application by running `npm run build:` +> where `` is the application folder name (e.g. `build:bff`, `build:storefrontapp`). +> The scripts above follow this convention. Please see the Hosting Portal docs +> [Hosting Portal documentation](https://help.sap.com/docs/SAP_COMMERCE_CLOUD_PUBLIC_CLOUD/83616e9e152b4d16aaa4ee747ca8cad7/bb67d998b3d943d9887f3a2d2fa98eff.html?state=DRAFT&profile=20682543&profile=20682543&ai=true&version=DEV&locale=en-US) +> for the full build and deployment requirements. + +--- + +### 11. `.env-cmdrc` *(create or modify, project root)* + +Create this file at the workspace root (or add to it if it already exists). Holds +`CX_BFF_BASE_URL` for each dev profile. Used **only** by `proxy.conf.js` at +dev-server startup — never read by the Angular app itself. + +To pass these variables automatically when starting the dev server, install `env-cmd` +and update the `start:storefrontapp` script: + +```bash +npm install --save-dev env-cmd +``` + +```json +"start:storefrontapp": "env-cmd -e dev nx serve storefrontapp" +``` + +```jsonc +{ + "dev": { + "CX_BASE_URL": "https://your-commerce-host", + "CX_BFF_BASE_URL": "https://localhost:8482/bff/api" + } +} +``` + +> **Note:** In `@vivaldi` 0.25.0 the BFF runs with a self-signed HTTPS cert on port 8482 +> with tRPC at `/bff/api`. The proxy forwards `/bff` to the BFF without path rewriting. + +--- + +### 12. Example: custom BFF procedure (`src/app/bff/examples/say-hello.component.ts`) + +Route: `/bff-say-hello` + +Calls `sample.sayHello` via `BffClientService`. The input type `{ name?: string }` and +return type `{ message: string }` are both inferred from `RootRouter` — no manual +annotations. TypeScript will error if either is wrong. + +```ts +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { BffClientService } from '../bff-client.service'; + +@Component({ + selector: 'app-say-hello', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule], + template: ` +

BFF Say Hello

+ + + @if (message()) {

{{ message() }}

} + @if (error()) {

{{ error() }}

} + `, +}) +export class SayHelloComponent { + private readonly bff = inject(BffClientService); + + name = ''; + message = signal(''); + error = signal(''); + + async sayHello(): Promise { + const res = await this.bff.client.sample.sayHello.query({ name: this.name }); + this.message.set(res.message); // res.message: string — inferred + } +} +``` + +--- + +### 13. Example: OCC call via BFF (`src/app/bff/examples/occ-base-sites.component.ts`) + +Route: `/occ-base-sites` + +Calls `occ.getBaseSites` via `BffClientService`. The BFF procedure proxies to +`GET /occ/v2/basesites` on the OCC backend. The return type is inferred from +`RootRouter`. + +```ts +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { BffClientService } from '../bff-client.service'; + +@Component({ + selector: 'app-occ-base-sites', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [JsonPipe], + template: ` +

OCC Base Sites (via BFF)

+ + @if (result()) {
{{ result() | json }}
} + @if (error()) {

{{ error() }}

} + `, +}) +export class OccBaseSitesComponent { + private readonly bff = inject(BffClientService); + + result = signal(null); + error = signal(''); + + async load(): Promise { + const res = await this.bff.client.occ.getBaseSites.query(); + this.result.set(res); // fully typed response from OCC + } +} +``` + +--- + +### 14. `app/src/bff/examples/bff-example.providers.ts` *(new file)* + +```ts +import { Provider } from '@angular/core'; +import { ROUTES } from '@angular/router'; + +export const bffExampleProviders: Provider[] = [ + { + provide: ROUTES, + multi: true, + useValue: [ + { + path: 'bff-say-hello', + loadComponent: () => + import('./say-hello.component').then((m) => m.SayHelloComponent), + }, + { + path: 'occ-base-sites', + loadComponent: () => + import('./occ-base-sites.component').then((m) => m.OccBaseSitesComponent), + }, + ], + }, +]; +``` + +Spread into `app.module.ts` providers: `providers: [privateProviders, ...bffExampleProviders]` + +```ts +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { AppRoutingModule } from '@spartacus/storefront'; +import { SpartacusModule } from './spartacus/spartacus.module'; +import { bffExampleProviders } from './bff/examples/bff-example.providers'; + +@NgModule({ + imports: [StoreModule.forRoot({}), EffectsModule.forRoot([]), AppRoutingModule, SpartacusModule], + providers: [...bffExampleProviders], +}) +export class AppModule {} +``` + +> **Important:** Register `bffExampleProviders` directly in `NgModule.providers`, not +> inside `makeEnvironmentProviders()`. Lazy routes registered via the `ROUTES` token +> inside `makeEnvironmentProviders()` cause Angular pending task leaks. + +--- + +## Known issue — `npm error code E401` on CCv2 build agents + +The `package-lock.json` generated by the Angular CLI may contain resolved URLs pointing +at the SAP Artifactory mirror (`common.repositories.cloud.sap/artifactory/...`). CCv2 +build agents that only have `SAP_RBSCTOKEN` cannot authenticate against Artifactory and +every tarball fetch fails with 401 (invisible at default log level). This does not affect +local development. + +If `npm install` fails with E401 on CCv2, regenerate the lockfile against the public +registry **before** pushing: + +```bash +rm -rf node_modules package-lock.json +NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ npm install +git add package-lock.json +git commit -m "chore: regenerate lockfile against public registry" +``` + +--- + +## Vivaldi BFF changes + +### 15. `apps/bff/env.d.ts` *(modify)* + +Declare `OCC_BASE_URL` so Vivaldi's typed env system recognises it: + +```ts +declare global { + interface VivaldiCustomEnv { + FRONTEND_BASE_URL: string; + OCC_BASE_URL: string; + } +} +export {}; +``` + +--- + +### 16. `apps/bff/vivaldi.apis.ts` *(modify)* + +Register the OCC backend as a host and expose it as the `occ_v2` destination. +Vivaldi auto-discovers this file — no change to `vivaldi.ts` needed. + +```ts +import { $, type VivaldiApi } from '@vivaldi/config'; + +const hosts = [ + { + name: 'occ', + origin: $.OCC_BASE_URL, + }, +] satisfies VivaldiApi['hosts']; + +export default { + hosts, + apis: [ + { + name: 'occ_v2', + destination: { + host: 'occ', + path: '/occ/v2', + }, + }, + ], +} satisfies VivaldiApi; +``` + +--- + +### 17. `packages/contracts/bff/destinations.ts` *(modify)* + +Expose `occ_v2` to the tRPC context so procedures can call `ctx.destinations.occ.v2()`: + +```ts +import { createDestinations } from '@vivaldi/config'; + +export default createDestinations(['occ_v2']); +``` + +--- + +### 18. `apps/bff/src/api/context.ts` *(modify)* + +Rewrite using a typed interface that extends `RequiredContext`. This is required so +the storefrontapp's typecheck can walk into BFF source files via the `@repo/bff/clients` +path alias without encountering `vivaldi.*` ambient globals, which are only declared in +`apps/bff/tsconfig.app.json` and not in the storefront tsconfig. + +```ts +import type { RequiredContext } from '@vivaldi/config'; +import { destinations } from '@repo/bff/contracts'; + +export interface Context extends RequiredContext { + greeting: string; +} + +export const createContext: () => Promise = async () => ({ + destinations, + greeting: 'Hello', +}); +``` + +> **Why this matters for type safety:** `@repo/bff/clients` re-exports `RootRouter` +> from the BFF router, which transitively imports `context.ts`. If `context.ts` +> references `vivaldi.env.isDev`, the storefront typecheck fails with +> `Cannot find name 'vivaldi'`. Keeping `context.ts` free of ambient globals ensures +> both the BFF and the storefront can type-check cleanly from the same source. + +--- + +### 19. `apps/bff/src/api/routers/occ.ts` *(new file)* + +tRPC router for OCC proxy procedures. Each procedure forwards to OCC via +`ctx.execute.http` and the `occ_v2` destination. The `Authorization` header from the +storefront is forwarded to OCC via `ctx.forwardHeaders`. + +The `occV2` helper casts `ctx.destinations` to the concrete type from +`@repo/bff/contracts` — necessary because the Vivaldi context types the destinations +map generically, but `createDestinations(['occ_v2'])` produces a strongly typed accessor. + +```ts +import { HttpRequestBuilder } from '@vivaldi/connectivity'; +import { destinations } from '@repo/bff/contracts'; +import { ProcedureParams } from '@vivaldi/trpc'; +import { z } from 'zod'; +import { Context } from '../context'; +import { publicProcedure, router } from '../trpc'; + +type TypedDestinations = typeof destinations; +const occV2 = (ctx: { destinations: Context['destinations'] }) => + (ctx.destinations as unknown as TypedDestinations).occ.v2(); + +const getBaseSitesHeaders = { + authorization: z.string().optional(), +}; + +export type getBaseSitesOptions = ProcedureParams< + Context, + z.ZodUndefined, + typeof getBaseSitesHeaders +>; + +export const getBaseSitesFn = async ({ ctx }: getBaseSitesOptions) => { + return ctx.execute.http( + HttpRequestBuilder.get('/basesites').addCustomHeaders({ + authorization: ctx.forwardHeaders['authorization'], + }), + occV2(ctx), + ); +}; + +export const occ = router({ + getBaseSites: publicProcedure + .meta({ headers: getBaseSitesHeaders }) + .query(getBaseSitesFn), +}); +``` + +To add more OCC procedures, follow the same pattern — `HttpRequestBuilder.get/post` +with the desired path (relative to `/occ/v2`), forwarded headers, and `occV2(ctx)`. + +--- + +### 20. `apps/bff/src/api/routers/root.ts` *(modify)* + +Register the new `occ` router alongside `sample`: + +```ts +import { createCallerFactory, router } from '../trpc'; +import { occ } from './occ'; +import { sample } from './sample'; + +export const rootRouter = router({ + occ, + sample, +}); + +export type RootRouter = typeof rootRouter; +export const createCaller = createCallerFactory(rootRouter); +``` + +--- + +### 21. `apps/bff/.env` *(local dev only)* + +Set `OCC_BASE_URL` for local BFF development. Must be a hostname with a **CA-signed +certificate** — the BFF container on CCv2 runs Node.js in production mode which rejects +self-signed certs. For deployed environments, set this in the CCv2 BFF VariableSet. + +```bash +OCC_BASE_URL=https://api.your-commerce-host.model-t.myhybris.cloud +``` + +--- + +## File overview + +### Spartacus storefront + +``` +src/ + index.html ← add bff-base-url meta tag + app/ + app.module.ts ← spread bffExampleProviders + bff/ + bff-base-url.token.ts ← InjectionToken reading meta tag + bff-error-handling.link.ts ← tRPC link: error propagation + bff-auth.link.ts ← tRPC link: Bearer token injection + bff-timeout.link.ts ← tRPC link: timeout + abort (dev mode) + bff-client.service.ts ← typed TRPCClient + examples/ + bff-example.providers.ts ← lazy route registration + say-hello.component.ts ← demo: custom BFF procedure (typed) + occ-base-sites.component.ts ← demo: OCC call via BFF (typed) +proxy.conf.js ← dev-server proxy (reads CX_BFF_BASE_URL) +package.json ← add build:bff, dev:bff, start:storefrontapp scripts +.env-cmdrc ← add CX_BFF_BASE_URL to dev profiles +project.json ← point serve to proxy.conf.js +``` + +### Vivaldi BFF + +``` +apps/bff/ + env.d.ts ← add OCC_BASE_URL env var type + vivaldi.apis.ts ← register OCC host + occ_v2 destination + src/api/ + context.ts ← keep free of vivaldi.* ambient globals + routers/ + occ.ts ← new router: OCC procedures + root.ts ← register occ router +packages/contracts/bff/ + destinations.ts ← add occ_v2 to createDestinations +``` + +--- + +## Testing locally + +```bash +# Terminal 1 — start the BFF dev server (HTTPS, self-signed cert, port 8482) +OCC_BASE_URL=https://your-occ-host npm run dev:bff + +# Terminal 2 — start Spartacus +npm run start:storefrontapp + +# Navigate to: +# http://localhost:4200/electronics-spa/en/USD/bff-say-hello +# http://localhost:4200/electronics-spa/en/USD/occ-base-sites +``` + +--- + +## Deployment notes + +### Required custom variables + +Two separate applications need environment variables configured in the CCv2 Hosting Portal. + +#### BFF application + +Set in the BFF application's Variable Set: + +| Variable | Description | +|---|---| +| `OCC_BASE_URL` | Full OCC API hostname with CA-signed certificate, e.g. `https://api.your-tenant.model-t.myhybris.cloud` | + +Without `OCC_BASE_URL` the BFF container fails to start with: +``` +HostOriginMissingError: Host "occ" has no origin configured. +``` + +#### Storefront application + +The storefront reads its OCC backend URL from the `occ-backend-base-url` meta tag in +`index.html`, which CCv2 replaces at deploy time using: + +| Variable | Placeholder replaced in `index.html` | Description | +|---|---|---| +| `OCC_BASE_URL` | `OCC_BACKEND_BASE_URL_VALUE` | OCC API hostname for the browser to call | + +Without this variable set, the placeholder remains unreplaced and Spartacus falls back +to `https://localhost:9002`, causing all OCC requests to fail. + +#### CA-signed certificate requirement + +Both variables must use a hostname with a CA-signed certificate — not a raw IP address +(e.g. `https://40.x.x.x:9002`). Node.js in production mode rejects self-signed +certificates, and the browser will also reject them without a manual exception. + +## References + +[Hosting Portal Documentation](https://help.sap.com/docs/SAP_COMMERCE_CLOUD_PUBLIC_CLOUD/83616e9e152b4d16aaa4ee747ca8cad7/bb67d998b3d943d9887f3a2d2fa98eff.html?state=DRAFT&profile=20682543&profile=20682543&ai=true&version=DEV&locale=en-US) + +[Composable Storefront for SAP Commerce Cloud](https://help.sap.com/docs/CC_CEE/24176fb554b4410caf1bcd0c6c7cf633/6698db3d8f92441ca3f364879e6bb4cf.html?locale=en-US&state=DRAFT&version=DEV&ai=true) +