Skip to content

Commit

Permalink
feat: ssr error handling (#18742)
Browse files Browse the repository at this point in the history
CXSPA-2066
  • Loading branch information
pawelfras authored Sep 10, 2024
1 parent 96e51c7 commit e2d50b7
Show file tree
Hide file tree
Showing 311 changed files with 5,018 additions and 1,120 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@
"@nx/workspace/use-provide-default-feature-toggles": "error",
"@nx/workspace/use-provide-default-feature-toggles-factory": "error"
}
},
{
"files": ["*.action*.ts"],
"rules": {
"@nx/workspace/no-ngrx-fail-action-without-error-action-implementation": "error"
}
}
]
}
29 changes: 27 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,32 @@ jobs:
BUILD_NUMBER: ci-build-number-${{ github.event.pull_request.head.sha || github.run_id }}
run: |
ci-scripts/e2e-cypress.sh -s b2b
ssr_tests:
needs: [no_retries, validate_e2e_execution]
name: SSR Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v2
with:
path: |
node_modules
key: nodemodules-${{ github.event.pull_request.base.sha }}
restore-keys: nodemodules-${{ github.event.pull_request.base.sha }}
- name: Package installation
run: npm ci
- name: Build SSR Server
run: npm run build:libs && npm run build && npm run build:ssr:local-http-backend
- name: Run SSR tests
run: npm run test:ssr:ci --verbose
build_conclusion:
needs: [no_retries, unit_tests, linting, b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests, sonarqube_scan]
needs: [no_retries, unit_tests, linting, b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests, ssr_tests, sonarqube_scan]
name: Build Conclusion
runs-on: ubuntu-latest
if: ${{ always() }}
Expand All @@ -234,7 +258,8 @@ jobs:
needs.linting.result == 'failure' || needs.linting.result == 'cancelled' ||
needs.b2c_e2e_tests.result == 'failure' || needs.b2c_e2e_tests.result == 'cancelled' ||
needs.b2c_ssr_e2e_tests.result == 'failure' || needs.b2c_ssr_e2e_tests.result == 'cancelled' ||
needs.b2b_e2e_tests.result == 'failure' || needs.b2b_e2e_tests.result == 'cancelled'
needs.b2b_e2e_tests.result == 'failure' || needs.b2b_e2e_tests.result == 'cancelled' ||
needs.ssr_tests.result == 'failure' || needs.ssr_tests.result == 'cancelled'
send_slack_message:
needs: build_conclusion
name: Slack message for failed CI build in Spartacus
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CxCommonEngine should handle APP_INITIALIZER errors the standard Angular way and throw if any occurred 1`] = `
"R3InjectorError(TokenServerModule)[InjectionToken SOME_TOKEN -> InjectionToken SOME_TOKEN]:
NullInjectorError: No provider for InjectionToken SOME_TOKEN!"
`;

exports[`CxCommonEngine should handle errors propagated from SSR 1`] = `"test error"`;

exports[`CxCommonEngine should not override providers passed to options 1`] = `"<html data-critters-container><head></head><body><cx-token ng-version="17.0.5" ng-server-context="ssr">message:test</cx-token></body></html>"`;

exports[`CxCommonEngine should return html if no errors 1`] = `"<html data-critters-container><head></head><body><cx-mock ng-version="17.0.5" ng-server-context="ssr">some template</cx-mock></body></html>"`;
130 changes: 130 additions & 0 deletions core-libs/setup/ssr/engine/cx-common-engine.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// eslint-disable-next-line import/no-unassigned-import
import '@angular/compiler';

import { Component, InjectionToken, NgModule, inject } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ServerModule } from '@angular/platform-server';
import { PROPAGATE_ERROR_TO_SERVER } from '../error-handling/error-response/propagate-error-to-server';
import { CxCommonEngine } from './cx-common-engine';

// Test how the CxCommonEngine handles successful server-side rendering
@Component({ selector: 'cx-mock', template: 'some template' })
export class SuccessComponent {}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [SuccessComponent],
bootstrap: [SuccessComponent],
})
export class SuccessServerModule {}

// Test how the CxCommonEngine handles propagated error
@Component({
selector: 'cx-response',
template: ``,
})
export class WithPropagatedErrorComponent {
constructor() {
inject(PROPAGATE_ERROR_TO_SERVER)(new Error('test error'));
}
}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [WithPropagatedErrorComponent],
bootstrap: [WithPropagatedErrorComponent],
})
export class WithPropagatedErrorServerModule {}

// Test that the CxCommonEngine doesn't override providers
// If SOME_TOKEN not provided, test how the CxCommonEngine handles APP_INITIALIZER errors
export const SOME_TOKEN = new InjectionToken<string>('SOME_TOKEN');

@Component({
selector: 'cx-token',
template: `message:{{ someToken }}`,
})
export class TokenComponent {
someToken = inject(SOME_TOKEN);
}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [TokenComponent],
bootstrap: [TokenComponent],
})
export class TokenServerModule {}

describe('CxCommonEngine', () => {
let engine: CxCommonEngine;

beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'log').mockImplementation();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should return html if no errors', async () => {
engine = new CxCommonEngine({
bootstrap: SuccessServerModule,
});

const html = await engine.render({
url: 'http://localhost:4200',
document: '<cx-mock></cx-mock>',
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
expect(html).toMatchSnapshot();
});

it('should not override providers passed to options', async () => {
engine = new CxCommonEngine({
bootstrap: TokenServerModule,
});

const html = await engine.render({
url: 'http://localhost:4200',
document: '<cx-token></cx-token>',
providers: [{ provide: SOME_TOKEN, useValue: 'test' }],
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
expect(html).toMatchSnapshot();
});

it('should handle APP_INITIALIZER errors the standard Angular way and throw if any occurred', async () => {
engine = new CxCommonEngine({
bootstrap: TokenServerModule,
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
await expect(
engine.render({
url: 'http://localhost:4200',
document: '<cx-token></cx-token>',
})
).rejects.toThrowErrorMatchingSnapshot();
});

it('should handle errors propagated from SSR', async () => {
engine = new CxCommonEngine({
bootstrap: WithPropagatedErrorServerModule,
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
await expect(
engine.render({
url: 'http://localhost:4200',
document: '<cx-response></cx-response>',
})
).rejects.toThrowErrorMatchingSnapshot();
});
});
67 changes: 67 additions & 0 deletions core-libs/setup/ssr/engine/cx-common-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import {
CommonEngine,
CommonEngineOptions,
CommonEngineRenderOptions,
} from '@angular/ssr';
import { PROPAGATE_ERROR_TO_SERVER } from '../error-handling/error-response/propagate-error-to-server';

/**
* The Spartacus extension of the Angular's `CommonEngine`. It is able to handle the propagated server responses caught during server-side rendering of a Spartacus app.
* For reference, see Angular's source code: https://github.com/angular/angular-cli/blob/6cf866225ab09f8b4b3803c000b632bed8448ce4/packages/angular/ssr/src/common-engine.ts#L56
*
* @extends {CommonEngine}
*/
export class CxCommonEngine extends CommonEngine {
constructor(options?: CommonEngineOptions) {
super(options);
}

/**
* @override
* Renders for the given options.
* If an error is populated from the rendered applications
* (via `PROPAGATE_ERROR_TO_SERVER` callback), then such an error
* will be thrown and the result promise rejected - but only AFTER the rendering is complete.
* In other words, at first an error occurs and it's captured, then we wait until the rendering completes
* and ONLY then we reject the promise with the payload being the encountered error.
*
* Note: if more errors are captured during the rendering, only the first one will be used
* as the payload of the rejected promise, others won't.
*
* @param {CommonEngineRenderOptions} options - The options to render.
* @returns {Promise<string>} Promise which resolves with the rendered HTML as a string
* OR rejects with the error, if any is propagated from the rendered app.
*/
override async render(options: CommonEngineRenderOptions): Promise<string> {
let error: undefined | unknown;

return super
.render({
...options,
providers: [
{
provide: PROPAGATE_ERROR_TO_SERVER,
useFactory: () => {
return (propagatedError: unknown) => {
// We're interested only the first propagated error, so we use `??=` instead of `=`:
error ??= propagatedError;
};
},
},
...(options.providers ?? []),
],
})
.then((html: string) => {
if (error) {
throw error;
}
return html;
});
}
}
2 changes: 1 addition & 1 deletion core-libs/setup/ssr/engine/ng-express-engine.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <[email protected]>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/
Expand Down
9 changes: 3 additions & 6 deletions core-libs/setup/ssr/engine/ng-express-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@
*/

import { StaticProvider } from '@angular/core';
import {
CommonEngine,
CommonEngineOptions,
CommonEngineRenderOptions,
} from '@angular/ssr';
import { CommonEngineOptions, CommonEngineRenderOptions } from '@angular/ssr';
import { Request, Response } from 'express';
import { REQUEST, RESPONSE } from '../tokens/express.tokens';
import { CxCommonEngine } from './cx-common-engine';

/**
* @license
Expand Down Expand Up @@ -80,7 +77,7 @@ export interface RenderOptions extends CommonEngineRenderOptions {
* - https://github.com/angular/universal/blob/e798d256de5e4377b704e63d993dc56ea35df97d/modules/express-engine/src/main.ts
*/
export function ngExpressEngine(setupOptions: NgSetupOptions) {
const engine = new CommonEngine({
const engine = new CxCommonEngine({
bootstrap: setupOptions.bootstrap,
providers: setupOptions.providers,
enablePerformanceProfiler: setupOptions.enablePerformanceProfiler,
Expand Down
7 changes: 7 additions & 0 deletions core-libs/setup/ssr/error-handling/error-response/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

export * from './propagate-error-to-server';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { InjectionToken } from '@angular/core';

/**
* Propagates the given error object to the higher layer in the server.
*
* It's meant to propagate errors for example to ExpressJS layer when using SSR
* or to a Prerendering Worker when using Server Prerendering.
* Currently, it's provided OOTB only in SSR (not prerendering), in the `CxCommonEngine` class.
*
* Note: We need it until Angular implements a proper propagation of async errors
* from an app to the the higher layer in the server.
* For more, see the Angular issue https://github.com/angular/angular/issues/33642
*/
export const PROPAGATE_ERROR_TO_SERVER = new InjectionToken<
(error: unknown) => void
>('PROPAGATE_ERROR_RESPONSE');
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { HttpErrorResponse } from '@angular/common/http';
import { CmsPageNotFoundOutboundHttpError } from '@spartacus/core';
import { defaultExpressErrorHandlers } from './express-error-handlers';

describe('expressErrorHandlers', () => {
let documentContent: string;
let req: any;
let res: any;
let next: any;

beforeEach(() => {
documentContent = 'some document content';
req = {};
res = {
set: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
});

it('should do nothing if headers are already sent', () => {
const err = new HttpErrorResponse({
error: 'Page not found',
});
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);
res.headersSent = true;

errorRequestHandler(err, req, res, next);

expect(res.set).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.send).not.toHaveBeenCalled();
});

it('should handle CmsPageNotFoundOutboundHttpError', () => {
const err = new CmsPageNotFoundOutboundHttpError('Page not found');
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);

errorRequestHandler(err, req, res, next);

expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith(documentContent);
});

it('should handle unknown error', () => {
const err = new Error('unknown error');
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);

errorRequestHandler(err, req, res, next);

expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.status).toHaveBeenCalledWith(500);
expect(res.send).toHaveBeenCalledWith(documentContent);
});
});
Loading

0 comments on commit e2d50b7

Please sign in to comment.