XDN Spartacus for SAP Commerce Cloud (formerly SAP Hybris)

Spartacus is the official JavaScript headless front end for SAP Commerce Cloud. You can read more about Spartacus at the official docs. Spartacus is written in Angular. Note that using Spartacus on the XDN requires an instance of SAP Commerce Cloud 1905 or later.

This repo is a Moovweb XDN optimized template of SAP Spartacus. It leverages the official SAP Spartacus template and adds libraries to support XDN features that enhance Spartacus such as,

  • CDN-as-JavaScript: configure the edge within your application
  • Serverless JavaScript: zero devops with unlimited scale to power Spartacus server-side rendering (SSR) and OCC API orchestration
  • Performance: deliver instant site page loads with server-side rendering, caching, and predictive prefetching
  • Iterative migration: adopt Spartacus gradually, one page at a time
  • Edge Experiments: experiment and use A/B testing without sacrificing speed

If you just want to get started quickly with Spartacus and deploy it to the XDN in a few minutes follow the Getting started section below.

The Building from scratch section describes how to manually recreate an XDN optimized version of Spartacus from the official libraries. You don't need to do these steps, but it's left there for the curious or for those trying to upgrade an existing Spartacus app.

Getting Started

Getting Started Tutorial Video

If you have not already done so, sign up for an account on the XDN Console and install the XDN CLI

npm i -g @xdn/cli

Next, run the XDN create module to pull down the XDN Spartacus template to your machine:

npm create xdn-app@latest

The XDN create module will ask you a series of questions to configure your app. Make sure you answer as follows:

  • For Select an app template select Spartacus
  • For Enter the hostname for the origin site (e.g. domain.com) enter the domain of the SAP Commerce Cloud server that will serve as the OCC API backend for Spartacus.

As an example, below is a sample transcript from running XDN create module:

$ npm create xdn-app@latest
✔ Enter a name for your app … my-xdn-site
✔ Select an app template › Spartacus
✔ Enter the hostname for the origin site (e.g. domain.com) … spartacusapiserver.mycompany.com
✔ Which package manager would you like to use? › npm

Next, configure the occBaseUrl in environment.prod.ts. If this is your first time getting started, the XDN will automatically assign you a URL of the format {username}-{project-name}-default.moovweb-edge.io where the project-name is pulled from the package.json of your project. For example, if your username is alice and your project has the name of my-xdn-site, then set the occBaseUrl in environment.prod.ts as follows and save your changes:

export const environment = {
  production: false,
  occBaseUrl: 'https://alice-my-xdn-site-default.moovweb-edge.io',
}

To run your app locally in development mode run xdn run. To emulate a serverless runtime locally run xdn run --serverless.

Deploying

Deploying requires an account on the Moovweb XDN. Sign up here for free. Once you have an account, you can deploy to the Moovweb XDN by running the following in the root folder of your project

xdn deploy

Be aware that the deploy step will automatically build Spartacus for you which can take a few minutes. When the deployment finishes, the output will confirm the final deployment URL. Below is an example:

📡️ Uploading...
> Uploading package
done (9425ms)

⌛ Deploying to the Moovweb XDN...
done (48565ms)

***** Deployment Complete ***************************************
*                                                               *
*  🖥  XDN Developer Console:                                   *
*  https://moovweb.app/alice/my-xdn-site/env/default/builds/1   *
*                                                               *
*  🌎 Website:                                                  *
*  https://alice-my-xdn-site-default.moovweb-edge.io            *
*                                                               *
*****************************************************************

Congrats! Your Spartacus site is now live on the XDN and you can login to the XDN Console to manage your project.

Building from scratch

This section describes how to manually recreate an XDN optimized version of Spartacus from the official libraries. We recommend using the pre-built template in this repository, but we've left these steps for those trying to upgrade an existing Spartacus app or looking to apply the XDN to a different version of Spartacus.

The steps below are pulled from the Spartacus official docs.

Make sure to install @angular/cli 8 if targeting a Spartacus version lower than v2. Spartacus v1 does not support 9. npm install -g @angular/cli@8

  1. Create an angular app

    ng new xdn-spartacus-app --style=scss
    cd xdn-spartacus-app

    When prompted if you would like to add Angular routing, enter n for ‘no’.

  2. Add the Spartacus scaffold via schematic

    ng add @spartacus/schematics --ssr

    Note the SSR parameter. This is needed for server-side rendering to work properly when deploying on the XDN.

  3. Replace the contents of src/app/app.component.html with:

    <cx-storefront>Loading...</cx-storefront>
  4. Update app.module.ts to include a baseSite configuration:

     B2cStorefrontModule.withConfig({
      backend: {
        occ: {
          baseUrl: 'https://localhost:9002',
          prefix: '/rest/v2/'
        }
      },
    + context: {
    +   baseSite: ['electronics-spa']
    + },
      i18n: {
        resources: translations,
        chunks: translationChunksConfig,
        fallbackLang: 'en'
      },
      features: {
        level: '1.5',
        anonymousConsents: true
      }
     }),

Preparing for deployment on the XDN

npm install -g @xdn/cli
xdn init

The app should now have @xdn dependencies installed and auto-generated routes.js and xdn.config.js files created by @xdn/angular.

The following three steps are necessary when using Spartacus 1.x / Angular 8. Angular 9 Universal has an Express server export by default, so these 3 steps can be skipped.

  1. Modify the output block of webpack.server.config.js to a UMD library target with default export

    output: {
    +   libraryTarget: 'umd',
    +   libraryExport: 'default',
        // Puts the output at the root of the dist folder
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
      },
  2. Have server.ts export the Express app and remove server initialization:

    -// Start up the Node server
    -app.listen(PORT, () => {
    -  console.log(`Node server listening on http://localhost:${PORT}`);
    -});
    +export default app
  3. Update xdn.config.js to specify the location of the server build:

    "use strict";
    // This file was automatically added by xdn deploy.
    // You should commit this file to source control.
    const { join } = require('path')
    module.exports = {
      server: {
    +   path: 'dist/server.js'
    -   path: 'dist/<your-project-name>-server/main.js',
    -   export: 'app'
      },
    }

Configure a backend in xdn.config.js that points to the commerce API:

// This file was automatically added by xdn deploy.
// You should commit this file to source control.
const { join } = require('path')
module.exports = {
  server: {
    path: join(__dirname, 'dist/server.js')
    export: 'app'
  },
+ backends: {
+   commerce: {
+     domainOrIp: 'aemspartacusapi.tmg.codes',
+     hostHeader: 'aemspartacusapi.tmg.codes',
+   },
+ }
}

Configure routes.js to proxy API and media requests to the Commerce backend:

// This file was automatically added by xdn deploy.
// You should commit this file to source control.
const { Router } = require('@xdn/core/Router')
const createAngularPlugin = require('@xdn/angular/router/createAngularPlugin')
module.exports = app => {
  const { angularMiddleware } = createAngularPlugin(app)
- return new Router().use(angularMiddleware)
+ return new Router()
+   .match('/rest/v2/:path*', ({ proxy }) => {
+     return proxy('commerce')
+   })
+   .match('/medias/:path*', ({ proxy }) => {
+     return proxy('commerce')
+   })
+   .use(angularMiddleware)
}

Here you can also configure all caching for individual paths.

Configure the commerce baseUrl to point to XDN.

In app.module.ts:

 B2cStorefrontModule.withConfig({
  backend: {
    occ: {
-     baseUrl: 'https://localhost:9002',
+     baseUrl: 'https://YOUR_XDN_DEPLOYMENT_URL'
      prefix: '/rest/v2/'
    }
  },
  context: {
    baseSite: ['electronics-spa']
  },
  i18n: {
    resources: translations,
    chunks: translationChunksConfig,
    fallbackLang: 'en'
  },
  features: {
    level: '1.5',
    anonymousConsents: true
  }
 }),

In ìndex.html:

-<meta name="occ-backend-base-url" content="https://localhost:9002" />
+<meta name="occ-backend-base-url" content="https://YOUR_XDN_DEPLOYMENT_URL" />

In environment.prod.ts:

environment = {
  production: true,
+ occBaseUrl: 'https://YOUR_XDN_DEPLOYMENT_URL',
};

Deploying to XDN

xdn deploy

Adding prefetching

Upstream request tracking

Prefetching for a Spartacus app can be enabled by listening to upstream requests made when server-side rendering a specific page. @xdn/prefetch library will pick up on the upstream requests made by reading the x-xdn-upstream-requests response header. An example scenario:

  1. User A lands on /product/1.
  2. /product/1 has not been cached in the edge and thus will be server-side rendered.
  3. The rendering server has been modified to track upstream requests by patching https.request.
  4. The rendering server sets x-xdn-upstream-requests to, for example: /rest/v2/1;/rest/v2/2;
  5. The HTML response for /product/1 is now cached and for future requests served from the edge along with the x-xdn-upstream-requests response header.
  6. User B lands on a page that has a link to /product/1. /product/:path* has been configured with cache.browser.spa: true. Because of this configuration, @xdn/prefetch will know to make a prefetch HEAD request for /product/1, and only if product/1 can be served from the edge will it prefetch all requests specified in x-xdn-upstream-requests response header.
  7. When User B click the link to /product/1, the navigation will be faster since the requests needed to render the new page will be in service worker cache.

Example implementation of upstream request tracking:

import 'zone.js/dist/zone-node'
import * as express from 'express'
import { join } from 'path'

+ // xdn
+ import * as http from 'http'
+ import * as https from 'https'
+ import createRenderCallback from '@xdn/spartacus/server/createRenderCallback'
+ import installXdnMiddleware from '@xdn/spartacus/server/installXdnMiddleware'


// Express server
const server = express()

+ installXdnMiddleware({ server, http, https });

const PORT = process.env.PORT || 4200
const DIST_FOLDER = join(process.cwd(), 'dist/<your-project-name>')

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {
  AppServerModuleNgFactory,
  LAZY_MODULE_MAP,
  ngExpressEngine,
  provideModuleMap,
} = require('./dist/<your-project-name>-server/main')

server.engine(
  'html',
  ngExpressEngine({
    bootstrap: AppServerModuleNgFactory,
    providers: [provideModuleMap(LAZY_MODULE_MAP)],
  }),
)

server.set('view engine', 'html')
server.set('views', DIST_FOLDER)

server.get(
  '*.*',
  express.static(DIST_FOLDER, {
    maxAge: '1y',
  }),
)

// All regular routes use the Universal engine
server.get('*', (req, res) => {
  res.render(
    'index',
    { req },
+   createRenderCallback(res),
  )
})

export default server

Service worker

Update the new xdn.config.js file, replacing <your-api-server> with your API server host. Also, make sure that the server path is the correct path to your server file:

module.exports = {
  server: {
    path: 'dist/server.js',
    export: 'app',
  },
  backends: {
    commerce: {
      domainOrIp: 'api-commerce.my-site.com',
      hostHeader: 'api-commerce.my-site.com'
    },
  },
}

Add a polyfill for window.process if not already present in polyfills.ts:

...
(window as any).process = {
  env: {
    'DEBUG_SW': true
  }
}
...

The build command places the built service-worker.js under dist so @xdn/angular will know to static serve the file.

Installing the service worker and any further prefetching will be handled by @xdn/prefetch by invoking the install function imported from @xdn/prefetch/window/install.

Example implementation in app.component.ts:

import { Component, OnInit, Inject } from '@angular/core'
import { isPlatformBrowser } from '@angular/common'
import { PLATFORM_ID } from '@angular/core'
+ import install from '@xdn/prefetch/window/install'

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  isBrowser: boolean
  title = '<your-project-name>'

  constructor(@Inject(PLATFORM_ID) platformId: Object) {
    this.isBrowser = isPlatformBrowser(platformId)
  }

  ngOnInit() {
+   setTimeout(() => {
+     if (this.isBrowser) {
+       install()
+     }
+   })
  }
}

To avoid Spartacus installing ngsw-worker, set production: false in environment.prod.ts as a temporary workaround:

pwa: {
- enabled: environment.production
+ enabled: false
},

You may also need to disable it in your app.module.ts file:

ServiceWorkerModule.register(
  'ngsw-worker.js',
  {
-   enabled: environment.production,
+   enabled: false
  }
),

Cache configuration

An example cache configuration to optimally support prefetching:

routes.js

// This file was automatically added by xdn deploy.
// You should commit this file to source control.

const { Router } = require('@xdn/core/Router')
const createAngularPlugin = require('@xdn/angular/router/createAngularPlugin')

const PAGE_TTL = 60 * 60 * 24
const FAR_FUTURE_TTL = 60 * 60 * 24 * 365 * 10

module.exports = app => {
  const { angularMiddleware } = createAngularPlugin(app)
  return new Router()
    .match('/rest/v2/:path*', ({ cache, proxy }) => {
      cache({
        browser: {
          maxAgeSeconds: PAGE_TTL,
          serviceWorkerSeconds: PAGE_TTL,
        },
        edge: {
          maxAgeSeconds: PAGE_TTL,
          staleWhileRevalidateSeconds: PAGE_TTL,
        },
      })
      return proxy('commerce')
    })
    .match('/medias/:path*', ({ cache, proxy }) => {
      cache({
        browser: {
          maxAgeSeconds: PAGE_TTL,
          serviceWorkerSeconds: PAGE_TTL,
        },
        edge: {
          maxAgeSeconds: FAR_FUTURE_TTL,
          staleWhileRevalidateSeconds: 60 * 60 * 24,
        },
      })
      return proxy('commerce')
    })
    .match('/Open-Catalogue/:path*', ({ cache }) => {
      cache({
        browser: {
          maxAgeSeconds: PAGE_TTL,
          serviceWorkerSeconds: PAGE_TTL,
          spa: true,
        },
        edge: {
          maxAgeSeconds: PAGE_TTL,
          staleWhileRevalidateSeconds: PAGE_TTL,
        },
      })
    })
    .match('/product/:path*', ({ cache }) => {
      cache({
        browser: {
          maxAgeSeconds: PAGE_TTL,
          serviceWorkerSeconds: PAGE_TTL,
          spa: true,
        },
        edge: {
          maxAgeSeconds: PAGE_TTL,
          staleWhileRevalidateSeconds: PAGE_TTL,
        },
      })
    })
    .match('/service-worker.js', ({ setResponseHeader, serviceWorker }) => {
      setResponseHeader('content-type', 'application/javascript')
      serviceWorker('dist/<your-project-name>/service-worker.js')
    })
    .use(angularMiddleware)
}

Notice the spa: true in /product/:path* and /Open-Catalogue/:path* browser cache configuration. These are both routes that can appear in the form of links on any given page. With spa: true, @xdn/prefetch will know to optimally only fully prefetch the upstream requests specified in the cached responses for those routes.

Add "skipLibCheck": true, to tsconfig.json to avoid type errors from workbox library during build.