Skip to content

Commit

Permalink
feat: support running ICM and PWA in hybrid mode (#99)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhhyi committed Feb 20, 2020
1 parent f39bd83 commit d95b36e
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 11 deletions.
86 changes: 75 additions & 11 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }],
})
);

Expand Down Expand Up @@ -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',
Expand All @@ -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');
Expand Down
42 changes: 42 additions & 0 deletions src/app/core/guards/hybrid-redirect.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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);
}
}
2 changes: 2 additions & 0 deletions src/app/core/store/core-store.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export const metaReducers: MetaReducer<any>[] = [ngrxStateTransferMeta];
CheckoutStoreModule,
ContentStoreModule,
EffectsModule.forRoot(coreEffects),
HybridStoreModule,
RestoreStoreModule,
ShoppingStoreModule,
StoreModule.forRoot(coreReducers, {
Expand Down
21 changes: 21 additions & 0 deletions src/app/core/store/hybrid/hybrid-store.module.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
24 changes: 24 additions & 0 deletions src/app/core/store/hybrid/hybrid.effects.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>('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))
);
}
17 changes: 17 additions & 0 deletions src/app/core/store/hybrid/hybrid.selectors.ts
Original file line number Diff line number Diff line change
@@ -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('$<channel>', state.channel)
.replace('$<lang>', locale.lang)
.replace('$<application>', application)
.replace('$<currency>', locale.currency)
);
3 changes: 3 additions & 0 deletions src/app/core/store/hybrid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// tslint:disable no-barrel-files
// API to access ngrx hybrid state
export * from './hybrid.selectors';
2 changes: 2 additions & 0 deletions src/environments/environment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface Environment {
icmBaseURL: string;
icmServer: string;
icmServerStatic: string;

// application specific
icmChannel: string;
icmApplication?: string;

Expand Down
35 changes: 35 additions & 0 deletions src/hybrid/default-url-mapping-table.spec.ts
Original file line number Diff line number Diff line change
@@ -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(/\$<.*?>/);
}
);
});
});
98 changes: 98 additions & 0 deletions src/hybrid/default-url-mapping-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const ICM_CONFIG_MATCH = `^/INTERSHOP/web/WFS/(?<channel>[\\w-]+)/(?<lang>[\\w-]+)/(?<application>[\\w-]+)/[\\w-]+`;
const PWA_CONFIG_BUILD = ';channel=$<channel>;lang=$<lang>;application=$<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/$<channel>/$<lang>/$<application>/$<currency>';

/**
* 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=(?<sku>[\\w-]+).*$`,
pwaBuild: `product/$<sku>${PWA_CONFIG_BUILD}`,
pwa: `^.*/product/([\\w-]+).*$`,
icmBuild: `ViewProduct-Start?SKU=$1`,
handledBy: 'pwa',
},
{
id: 'Category Page',
icm: `${ICM_CONFIG_MATCH}/ViewStandardCatalog-Browse.*(\\?|&)CatalogID=(?<catalog>[\\w-]+).*$`,
pwaBuild: `category/$<catalog>${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=(?<uid>[^&]+)&Hash=(?<hash>[0-9a-f-]+).*$`,
pwaBuild: `forgotPassword/updatePassword?uid=$<uid>&Hash=$<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=($<id>.*?)(&.*|)$`,
pwaBuild: `page/$<id>${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',
},
];
1 change: 1 addition & 0 deletions src/main.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit d95b36e

Please sign in to comment.