From b1b71cb3e01b2c71cf1ecd5d3521a62dcaa8452d Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 22 Jan 2020 09:41:15 +0100 Subject: [PATCH] feat: support running ICM and PWA in hybrid mode --- server.ts | 86 +++++++++++++--- src/app/core/guards/hybrid-redirect.guard.ts | 42 ++++++++ src/app/core/store/core-store.module.ts | 2 + .../core/store/hybrid/hybrid-store.module.ts | 21 ++++ src/app/core/store/hybrid/hybrid.effects.ts | 24 +++++ src/app/core/store/hybrid/hybrid.selectors.ts | 17 ++++ src/app/core/store/hybrid/index.ts | 3 + src/environments/environment.model.ts | 2 + src/hybrid/default-url-mapping-table.spec.ts | 35 +++++++ src/hybrid/default-url-mapping-table.ts | 98 +++++++++++++++++++ src/main.server.ts | 1 + tslint.json | 1 + 12 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/app/core/guards/hybrid-redirect.guard.ts create mode 100644 src/app/core/store/hybrid/hybrid-store.module.ts create mode 100644 src/app/core/store/hybrid/hybrid.effects.ts create mode 100644 src/app/core/store/hybrid/hybrid.selectors.ts create mode 100644 src/app/core/store/hybrid/index.ts create mode 100644 src/hybrid/default-url-mapping-table.spec.ts create mode 100644 src/hybrid/default-url-mapping-table.ts diff --git a/server.ts b/server.ts index 69fda3072c..d8690a8fae 100644 --- a/server.ts +++ b/server.ts @@ -31,6 +31,11 @@ const { AppServerModuleNgFactory, LAZY_MODULE_MAP, ngExpressEngine, provideModul import { Environment } from 'src/environments/environment.model'; const environment: Environment = require('./dist/server/main').environment; +// tslint:disable-next-line: ban-specific-imports +import { HybridMappingEntry } from 'src/hybrid/default-url-mapping-table'; +const HYBRID_MAPPING_TABLE: HybridMappingEntry[] = require('./dist/server/main').HYBRID_MAPPING_TABLE; +const ICM_WEB_URL: string = require('./dist/server/main').ICM_WEB_URL; + const logging = !!process.env.LOGGING; // Express server @@ -58,7 +63,7 @@ app.engine( 'html', ngExpressEngine({ bootstrap: AppServerModuleNgFactory, - providers: [provideModuleMap(LAZY_MODULE_MAP)], + providers: [provideModuleMap(LAZY_MODULE_MAP), { provide: 'SSR_HYBRID', useValue: !!process.env.SSR_HYBRID }], }) ); @@ -129,15 +134,9 @@ const icmProxy = proxy(ICM_BASE_URL, { preserveHostHdr: true, }); -if (process.env.PROXY_ICM) { - console.log("making ICM available for all requests to '/INTERSHOP'"); - app.use('/INTERSHOP', icmProxy); -} - -// All regular routes use the Universal engine -app.get('*', (req: express.Request, res: express.Response) => { +const angularUniversal = (req: express.Request, res: express.Response) => { if (logging) { - console.log(`GET ${req.url}`); + console.log(`SSR ${req.originalUrl}`); } res.render( 'index', @@ -156,14 +155,79 @@ app.get('*', (req: express.Request, res: express.Response) => { res.status(500).send(err.message); } if (logging) { - console.log(`RES ${res.statusCode} ${req.url}`); + console.log(`RES ${res.statusCode} ${req.originalUrl}`); if (err) { console.log(err); } } } ); -}); +}; + +const hybridRedirect = (req, res, next) => { + const url = req.originalUrl; + let newUrl: string; + for (const entry of HYBRID_MAPPING_TABLE) { + const icmUrlRegex = new RegExp(entry.icm); + const pwaUrlRegex = new RegExp(entry.pwa); + if (icmUrlRegex.exec(url) && entry.handledBy === 'pwa') { + newUrl = url.replace(icmUrlRegex, '/' + entry.pwaBuild); + break; + } else if (pwaUrlRegex.exec(url) && entry.handledBy === 'icm') { + const config: { [is: string]: string } = {}; + let locale; + if (/;lang=[\w_]+/.test(url)) { + const [, lang] = /;lang=([\w_]+)/.exec(url); + if (lang !== 'default') { + locale = environment.locales.find(loc => loc.lang === lang); + } + } + if (!locale) { + locale = environment.locales[0]; + } + config.lang = locale.lang; + config.currency = locale.currency; + + if (/;channel=[^;]*/.test(url)) { + config.channel = /;channel=([^;]*)/.exec(url)[1]; + } else { + config.channel = environment.icmChannel; + } + + if (/;application=[^;]*/.test(url)) { + config.application = /;application=([^;]*)/.exec(url)[1]; + } else { + config.application = environment.icmApplication || '-'; + } + + const build = [ICM_WEB_URL, entry.icmBuild] + .join('/') + .replace(/\$<(\w+)>/g, (match, group) => config[group] || match); + newUrl = url.replace(pwaUrlRegex, build).replace(/;.*/g, ''); + break; + } + } + if (newUrl) { + if (logging) { + console.log('RED', newUrl); + } + res.redirect(301, newUrl); + } else { + next(); + } +}; + +if (process.env.SSR_HYBRID) { + app.use('*', hybridRedirect); +} + +if (process.env.PROXY_ICM || process.env.SSR_HYBRID) { + console.log("making ICM available for all requests to '/INTERSHOP'"); + app.use('/INTERSHOP', icmProxy); +} + +// All regular routes use the Universal engine +app.use('*', angularUniversal); if (process.env.SSL) { const https = require('https'); diff --git a/src/app/core/guards/hybrid-redirect.guard.ts b/src/app/core/guards/hybrid-redirect.guard.ts new file mode 100644 index 0000000000..0b559dc33f --- /dev/null +++ b/src/app/core/guards/hybrid-redirect.guard.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, CanActivateChild, RouterStateSnapshot } from '@angular/router'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { getICMWebURL } from 'ish-core/store/hybrid'; + +import { HYBRID_MAPPING_TABLE } from '../../../hybrid/default-url-mapping-table'; + +@Injectable({ providedIn: 'root' }) +export class HybridRedirectGuard implements CanActivate, CanActivateChild { + constructor(private store$: Store<{}>) {} + + private checkRedirect(url: string): boolean | Observable { + return this.store$.pipe( + select(getICMWebURL), + map(icmWebUrl => { + for (const entry of HYBRID_MAPPING_TABLE) { + if (entry.handledBy === 'pwa') { + continue; + } + const regex = new RegExp(entry.pwa); + if (regex.exec(url)) { + const newUrl = url.replace(regex, `${icmWebUrl}/${entry.icmBuild}`); + location.assign(newUrl); + return false; + } + } + return true; + }) + ); + } + + canActivate(_, state: RouterStateSnapshot) { + return this.checkRedirect(state.url); + } + + canActivateChild(_, state: RouterStateSnapshot) { + return this.checkRedirect(state.url); + } +} diff --git a/src/app/core/store/core-store.module.ts b/src/app/core/store/core-store.module.ts index 7e0a58fe00..8b8646f682 100644 --- a/src/app/core/store/core-store.module.ts +++ b/src/app/core/store/core-store.module.ts @@ -18,6 +18,7 @@ import { CountriesEffects } from './countries/countries.effects'; import { countriesReducer } from './countries/countries.reducer'; import { ErrorEffects } from './error/error.effects'; import { errorReducer } from './error/error.reducer'; +import { HybridStoreModule } from './hybrid/hybrid-store.module'; import { LocaleEffects } from './locale/locale.effects'; import { localeReducer } from './locale/locale.reducer'; import { MessagesEffects } from './messages/messages.effects'; @@ -66,6 +67,7 @@ export const metaReducers: MetaReducer[] = [ngrxStateTransferMeta]; CheckoutStoreModule, ContentStoreModule, EffectsModule.forRoot(coreEffects), + HybridStoreModule, RestoreStoreModule, ShoppingStoreModule, StoreModule.forRoot(coreReducers, { diff --git a/src/app/core/store/hybrid/hybrid-store.module.ts b/src/app/core/store/hybrid/hybrid-store.module.ts new file mode 100644 index 0000000000..642c478405 --- /dev/null +++ b/src/app/core/store/hybrid/hybrid-store.module.ts @@ -0,0 +1,21 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, NgModule, PLATFORM_ID } from '@angular/core'; +import { TransferState } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { EffectsModule } from '@ngrx/effects'; + +import { HybridRedirectGuard } from 'ish-core/guards/hybrid-redirect.guard'; +import { addGlobalGuard } from 'ish-core/utils/routing'; + +import { HybridEffects, SSR_HYBRID_STATE } from './hybrid.effects'; + +@NgModule({ + imports: [EffectsModule.forFeature([HybridEffects])], +}) +export class HybridStoreModule { + constructor(router: Router, transferState: TransferState, @Inject(PLATFORM_ID) platformId: string) { + if (isPlatformBrowser(platformId) && transferState.get(SSR_HYBRID_STATE, false)) { + addGlobalGuard(router, HybridRedirectGuard); + } + } +} diff --git a/src/app/core/store/hybrid/hybrid.effects.ts b/src/app/core/store/hybrid/hybrid.effects.ts new file mode 100644 index 0000000000..558a0944da --- /dev/null +++ b/src/app/core/store/hybrid/hybrid.effects.ts @@ -0,0 +1,24 @@ +import { isPlatformServer } from '@angular/common'; +import { Inject, Optional, PLATFORM_ID } from '@angular/core'; +import { TransferState, makeStateKey } from '@angular/platform-browser'; +import { Actions, Effect } from '@ngrx/effects'; +import { filter, take, tap } from 'rxjs/operators'; + +export const SSR_HYBRID_STATE = makeStateKey('ssrHybrid'); + +export class HybridEffects { + constructor( + private actions: Actions, + @Inject(PLATFORM_ID) private platformId: string, + private transferState: TransferState, + @Optional() @Inject('SSR_HYBRID') private ssrHybridState: boolean + ) {} + + @Effect({ dispatch: false }) + propagateSSRHybridPropToTransferState$ = this.actions.pipe( + take(1), + filter(() => isPlatformServer(this.platformId)), + filter(() => !!this.ssrHybridState), + tap(() => this.transferState.set(SSR_HYBRID_STATE, true)) + ); +} diff --git a/src/app/core/store/hybrid/hybrid.selectors.ts b/src/app/core/store/hybrid/hybrid.selectors.ts new file mode 100644 index 0000000000..27a64ce294 --- /dev/null +++ b/src/app/core/store/hybrid/hybrid.selectors.ts @@ -0,0 +1,17 @@ +import { createSelector } from '@ngrx/store'; + +import { getConfigurationState, getICMApplication } from 'ish-core/store/configuration'; +import { getCurrentLocale } from 'ish-core/store/locale'; + +import { ICM_WEB_URL } from '../../../../hybrid/default-url-mapping-table'; + +export const getICMWebURL = createSelector( + getConfigurationState, + getCurrentLocale, + getICMApplication, + (state, locale, application) => + ICM_WEB_URL.replace('$', state.channel) + .replace('$', locale.lang) + .replace('$', application) + .replace('$', locale.currency) +); diff --git a/src/app/core/store/hybrid/index.ts b/src/app/core/store/hybrid/index.ts new file mode 100644 index 0000000000..b409b1d147 --- /dev/null +++ b/src/app/core/store/hybrid/index.ts @@ -0,0 +1,3 @@ +// tslint:disable no-barrel-files +// API to access ngrx hybrid state +export * from './hybrid.selectors'; diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index e73912b77f..27939fab84 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -9,6 +9,8 @@ export interface Environment { icmBaseURL: string; icmServer: string; icmServerStatic: string; + + // application specific icmChannel: string; icmApplication?: string; diff --git a/src/hybrid/default-url-mapping-table.spec.ts b/src/hybrid/default-url-mapping-table.spec.ts new file mode 100644 index 0000000000..d1981b5707 --- /dev/null +++ b/src/hybrid/default-url-mapping-table.spec.ts @@ -0,0 +1,35 @@ +import { HYBRID_MAPPING_TABLE, ICM_WEB_URL } from './default-url-mapping-table'; + +describe('Default Url Mapping Table', () => { + describe('ICM_WEB_URL', () => { + it('should only contain placeholders for supported properties', () => { + const supported = ['channel', 'lang', 'application', 'currency']; + const allReplaced = supported.reduce((acc, val) => acc.replace(`\$<${val}>`, 'something'), ICM_WEB_URL); + expect(allReplaced).not.toMatch(/\$<.*?>/); + }); + }); + + describe('HYBRID_MAPPING_TABLE', () => { + it.each(HYBRID_MAPPING_TABLE.map(e => e.icm))(`{icm: '%p'} should be a valid regex`, entry => { + expect(() => new RegExp(entry)).not.toThrow(); + }); + + it.each(HYBRID_MAPPING_TABLE.map(e => e.pwa))(`{pwa: '%p'} should be a valid regex`, entry => { + expect(() => new RegExp(entry)).not.toThrow(); + }); + + it.each(HYBRID_MAPPING_TABLE.map(e => e.pwa))( + `{pwa: '%p'} should not use named capture groups due to browser compatibility`, + entry => { + expect(entry).not.toMatch(/\(\?<.*?>.*?\)/); + } + ); + + it.each(HYBRID_MAPPING_TABLE.map(e => e.icmBuild))( + `{icmBuild: '%p'} should not use named capture group replacements due to browser compatibility`, + entry => { + expect(entry).not.toMatch(/\$<.*?>/); + } + ); + }); +}); diff --git a/src/hybrid/default-url-mapping-table.ts b/src/hybrid/default-url-mapping-table.ts new file mode 100644 index 0000000000..f137e9040a --- /dev/null +++ b/src/hybrid/default-url-mapping-table.ts @@ -0,0 +1,98 @@ +const ICM_CONFIG_MATCH = `^/INTERSHOP/web/WFS/(?[\\w-]+)/(?[\\w-]+)/(?[\\w-]+)/[\\w-]+`; +const PWA_CONFIG_BUILD = ';channel=$;lang=$;application=$;redirect=1'; + +export interface HybridMappingEntry { + /** ID for grouping */ + id: string; + /** regex for detecting ICM URL */ + icm: string; + /** regex for building PWA URL */ + pwaBuild: string; + /** regex for detecting PWA URL */ + pwa: string; + /** regex for building ICM URL (w/o web url) */ + icmBuild: string; + /** handler */ + handledBy: 'icm' | 'pwa'; +} + +/** + * base for generating ICM URLs. + * + * usable variables: + * - channel + * - lang + * - application + * - currency + */ +export const ICM_WEB_URL = '/INTERSHOP/web/WFS/$/$/$/$'; + +/** + * Mapping table for running PWA and ICM in parallel + */ +export const HYBRID_MAPPING_TABLE: HybridMappingEntry[] = [ + { + id: 'Home', + icm: `${ICM_CONFIG_MATCH}/(Default-Start|ViewHomepage-Start).*$`, + pwaBuild: `home${PWA_CONFIG_BUILD}`, + pwa: `^/home.*$`, + icmBuild: `ViewHomepage-Start`, + handledBy: 'pwa', + }, + { + id: 'Product Detail Page', + icm: `${ICM_CONFIG_MATCH}/ViewProduct-Start.*(\\?|&)SKU=(?[\\w-]+).*$`, + pwaBuild: `product/$${PWA_CONFIG_BUILD}`, + pwa: `^.*/product/([\\w-]+).*$`, + icmBuild: `ViewProduct-Start?SKU=$1`, + handledBy: 'pwa', + }, + { + id: 'Category Page', + icm: `${ICM_CONFIG_MATCH}/ViewStandardCatalog-Browse.*(\\?|&)CatalogID=(?[\\w-]+).*$`, + pwaBuild: `category/$${PWA_CONFIG_BUILD}`, + pwa: `^.*/category/([\\w-]+).*$`, + icmBuild: `ViewStandardCatalog?CatalogID=$1&CategoryName=$1`, + handledBy: 'pwa', + }, + { + id: 'Shopping Basket', + icm: `${ICM_CONFIG_MATCH}/.*ViewCart-View$`, + pwaBuild: `basket${PWA_CONFIG_BUILD}`, + pwa: '^/basket.*$', + icmBuild: 'ViewCart-View', + handledBy: 'pwa', + }, + { + id: 'Login', + icm: `${ICM_CONFIG_MATCH}/ViewUserAccount-ShowLogin.*$`, + pwaBuild: `login${PWA_CONFIG_BUILD}`, + pwa: '^/login.*$', + icmBuild: 'ViewUserAccount-ShowLogin', + handledBy: 'pwa', + }, + { + id: 'Password Reset', + icm: `${ICM_CONFIG_MATCH}/ViewForgotLoginData-NewPassword\\?uid=(?[^&]+)&Hash=(?[0-9a-f-]+).*$`, + pwaBuild: `forgotPassword/updatePassword?uid=$&Hash=$${PWA_CONFIG_BUILD}`, + pwa: `^/forgotPassword/updatePassword?uid=([^&]+)&Hash=([0-9a-f-]+).*$`, + icmBuild: 'ViewForgotLoginData-NewPassword\\?uid=$1&Hash=$2', + handledBy: 'pwa', + }, + { + id: 'Content Pages', + icm: `${ICM_CONFIG_MATCH}/ViewContent-Start\\?PageletEntryPointID=($.*?)(&.*|)$`, + pwaBuild: `page/$${PWA_CONFIG_BUILD}`, + pwa: '^/page/(.*)$', + icmBuild: 'ViewContent-Start?PageletEntryPointID=$1', + handledBy: 'icm', + }, + { + id: 'My Account', + icm: `${ICM_CONFIG_MATCH}/ViewUserAccount-Start.*$`, + pwaBuild: `account${PWA_CONFIG_BUILD}`, + pwa: '^/account.*$', + icmBuild: 'ViewUserAccount-Start', + handledBy: 'icm', + }, +]; diff --git a/src/main.server.ts b/src/main.server.ts index 5e372d497c..85dd229e73 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -10,3 +10,4 @@ export { AppServerModule } from './app/app.server.module'; export { ngExpressEngine } from '@nguniversal/express-engine'; export { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader'; export { environment } from './environments/environment'; +export { HYBRID_MAPPING_TABLE, ICM_WEB_URL } from './hybrid/default-url-mapping-table'; diff --git a/tslint.json b/tslint.json index a6a7c3380f..e3af158236 100644 --- a/tslint.json +++ b/tslint.json @@ -488,6 +488,7 @@ "^.*/cypress/.*$", "^.*/src/environments/environment(\\.\\w+|)\\.ts$", "^.*/src/ngrx-router/.*$", + "^.*/src/hybrid/default-url-mapping-table.ts$", // core "^.*/src/app/core/[a-z][a-z0-9-]+\\.module\\.ts", "^.*/src/app/core/configurations/.*",