Skip to content

Commit

Permalink
Merge branch 'client/ssr'
Browse files Browse the repository at this point in the history
  • Loading branch information
winwiz1 committed Mar 21, 2020
2 parents 27f62a3 + 231fb60 commit 1f9f656
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 41 deletions.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

* Caching. The backend implements HTTP caching and allows long term storage of script bundles in browser's cache that further enhances performance yet supports smooth deployment of versioning changes in production (eliminating the risk of stale bundles getting stuck in the cache).

* Code splitting. Ability to optionally split your React Application into multiple Single Page Applications (SPA). For example, one SPA can offer an introductory set of screens for the first-time user or handle login. Another SPA could implement the rest of the application, except for Auditing or Reporting that can be catered for by yet another SPA. This approach would be beneficial for medium-to-large React applications that can be split into several domains of functionality, development and testing. To achieve better performance it's recommended to split when the size of a production bundle reaches 100 KB.
* Code splitting. Based on innovative ability to optionally split your React Application into multiple Single Page Applications (SPA). For example, one SPA can offer an introductory set of screens for the first-time user or handle login. Another SPA could implement the rest of the application, except for Auditing or Reporting that can be catered for by yet another SPA. This approach would be beneficial for medium-to-large React applications that can be split into several domains of functionality, development and testing. To achieve better performance it's recommended to split when the size of a production bundle reaches 100 KB.

* Seamless debugging. Debug a minified/obfuscated, compressed production bundle and put breakpoints in its TypeScript code using both VS Code and Chrome DevTools. Development build debugging: put breakpoints in the client and backend code and debug both simultaneously using a single instance of VS Code.

Expand All @@ -38,6 +38,18 @@
The implementation provides reusable code, both client-side and backend, making it easier to switch to another API. In fact this approach has been taken by the sibling Crisp BigQuery repository created by cloning and renaming this solution - it uses Google BigQuery API instead.<br/>
This arrangement brings a security benefit: The clients running inside a browser in a non-trusted environment do not have credentials to access a cloud service that holds sensitive data. The backend runs in the trusted environment you control and does have the credentials.

* SSR. Build-time SSR (also known as prerendering) is supported. The solution allows to selectively turn the SSR on or off for the chosen parts (e.g. SPAs) of the React application. This innovative flexibility is important because as noted by the in-depth [article](https://developers.google.com/web/updates/2019/02/rendering-on-the-web) on this subject, SSR is not a good recipe for every project and comes with costs. For example, the costs analysis could lead to a conclusion the Login part of an application is a good fit for SSR whereas the Reporting module is not. Implementing each part as an SPA with selectively enabled/disabled SSR would provide an optimal implementation and resolve this design disjuncture.

The SSR related costs depend on:

- Implementation complexity that results in a larger and more knotty codebase to maintain. That in turn leads to more potential problems while implementing the required functionality, writing test cases and resolving support issues.

- Run-time computing overhead causing [server delays](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-vs-static) (for run-time SSR) thus defeating or partially offsetting the performance benefits of SSR.

- Run-time computing overhead reducing the ability to sustain workloads (for run-time SSR coupled with complex or long HTML markup) which makes it easier to mount DOS attack aimed at webserver CPU exhaustion. In a case of cloud deployment, the frequency of malicious requests could be low enough to avoid triggering DDOS protection offered by the cloud vendor yet sufficient to saturate the server CPU and trigger autoscaling thus increasing the monetary cost. This challenge can be mitigated using a rate limiter which arguably should be an integral part of run-time SSR offerings.

Choosing build-time SSR allows to exclude the last two costs and effectively mitigate the first one by providing a concise implementation comprised of just few small source [files](https://github.com/winwiz1/crisp-react/tree/master/client/src/utils/ssr). The implementation is triggered as an optional post-build step and is consistent with script bundle compression also performed at the build time to avoid loading the webserver CPU.

* Containerisation. Docker multi-staged build is used to ensure the backend run-time environment doesn't contain the client build-time dependencies e.g. `client/node_modules/`. It improves security and reduces container's storage footprint.

- As a container deployment option suitable for a demonstration, you can build and deploy the container on Cloud Run. The prerequisites are to have a Google Cloud account with at least one project created and billing enabled.<br/>
Expand All @@ -57,6 +69,7 @@ It can be conveniently executed from the Cloud Shell session opened during the d
- [Usage](#usage)
- [Client Usage Scenarios](#client-usage-scenarios)
- [Backend Usage Scenarios](#backend-usage-scenarios)
- [SSR](#ssr)
- [Containerisation](#containerisation)
- [What's Next](#whats-next)
- [Pitfall Avoidance](#pitfall-avoidance)
Expand Down Expand Up @@ -315,6 +328,11 @@ Edit file `client/webpack.config.js` to change the `sourceMap` setting of the Te
Start the debugging configuration `Debug Production Client and Backend (workspace)`.<br/>
Wait until an instance of Chrome starts. You should see the overview page. Now you can use VS Code to set breakpoints in both client and backend provided the relevant process is highlighted/selected as explained in the previous scenario. You can also use Chrome DevTools to debug the client application as shown above.<br/>
To finish stop the running debugging configuration (use the Debugging toolbar or press `Control+F5` once).
## SSR
### Turning On and Off on the Application Level
SSR is enabled for production builds. In order to turn it off rename the `postbuild:prod` script in [`package.json`](https://github.com/winwiz1/crisp-react/blob/master/client/package.json), for example prepend an underscore to the script name. This will reduce the build time.
### Turning On and Off on the SPA Level
By default SSR is disabled for the [`first`](https://github.com/winwiz1/crisp-react/blob/master/client/src/entrypoints/first.tsx) SPA and enabled for the [`second`](https://github.com/winwiz1/crisp-react/blob/master/client/src/entrypoints/second.tsx) SPA. To toggle this setting follow the instructions provided in the respective file comments.
## Containerisation
### Using Docker
To build a Docker container image and start it, execute [`start-container.cmd`](https://github.com/winwiz1/crisp-react/blob/master/start-container.cmd) or [`start-container.sh`](https://github.com/winwiz1/crisp-react/blob/master/start-container.sh). Both files can also be executed from an empty directory in which case uncomment the two lines at the top. Moreover, it can be copied to a computer or VM that doesn't have NodeJS installed. The only prerequisites are Docker and Git.
Expand Down Expand Up @@ -398,6 +416,9 @@ A: Open the Settings page of the Chrome DevTools and ensure 'Enable JavaScript s
Q: Breakpoints in VS Code are not hit. How can it be fixed.<br/>
A: Try to remove the breakpoint and set it again. If the breakpoint is in the client code, refresh the page.

Q: I need to add Redux.<br/>
A: Have a look at the sibling Crisp BigQuery repository created by cloning and renaming this solution. It uses Redux.

Q: Linting the client and the backend yields a couple of errors. How do I fix it?<br/>
A: The linting errors left unfixed are either erroneous or are considered to be harmless and not worth fixing until the planned transition from tslint to eslint is completed.
## License
Expand Down
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"scripts": {
"build": "webpack",
"build:prod": "webpack --env.prod",
"postbuild:prod": "cross-env TS_NODE_PROJECT=tsconfig.ssr.json node -r ts-node/register -r tsconfig-paths/register src/utils/ssr/buildTimeSSR.ts",
"compile": "tsc -p .",
"lint": "tslint -p .",
"dev": "webpack-dev-server --config webpack.config.js",
Expand Down Expand Up @@ -73,6 +74,8 @@
"style-loader": "1.1.3",
"ts-jest": "^25.2.0",
"ts-loader": "6.2.1",
"ts-node": "^8.6.2",
"tsconfig-paths": "^3.9.0",
"tsconfig-paths-webpack-plugin": "^3.2.0",
"tslib": "1.10.0",
"tslint": "6.0.0",
Expand Down
21 changes: 13 additions & 8 deletions client/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as ReactDOM from "react-dom";
import { style } from "typestyle";
import { isCustomError } from "../utils/typeguards";
import logger from "../utils/logger";
import { isServer } from "../utils/ssr/misc";

type ErrorBoundaryState = {
hasError: boolean
Expand All @@ -31,7 +32,7 @@ export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
const errMsg = this.state.errDescription + "\n" + errInfo.componentStack;
logger.error(errMsg);
this.setState(prevState =>
({ ...prevState, errDescription: errMsg })
({ ...prevState, errDescription: errMsg })
);
}
}
Expand All @@ -44,13 +45,17 @@ export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {

public render() {
if (this.state.hasError) {
return (
<PortalCreator
onClose={this.onClick}
errorHeader="Error"
errorText={this.state.errDescription!}
/>
);
return isServer() ?
// Using console at build time is acceptable.
// tslint:disable-next-line:no-console
(console.error(this.state.errDescription!), <>{`SSR Error: ${this.state.errDescription!}`}</>) :
(
<PortalCreator
onClose={this.onClick}
errorHeader="Error"
errorText={this.state.errDescription!}
/>
);
} else {
return this.props.children;
}
Expand Down
69 changes: 49 additions & 20 deletions client/src/entrypoints/first.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,62 @@
/**
* In webpack terminology the 'entry point'
* The 'entry point' (in webpack terminology)
* of the First SPA.
*
* SSR has been disabled for this entry point.
* To enable SSR:
* - uncomment import of renderToString
* - replace ReactDOM.render with ReactDOM.hydrate (see comments below),
* - uncomment the SSR block at the bottom.
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Helmet } from "react-helmet";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { Router, Route, Switch } from "react-router-dom";
import { ComponentA } from "../components/ComponentA";
import { ComponentB } from "../components/ComponentB";
import { Overview } from "../components/Overview";
import { NameLookup } from "../components/NameLookup";
import { ErrorBoundary } from "../components/ErrorBoundary";
// import { renderToString } from "react-dom/server"; // used for SSR
import * as SPAs from "../../config/spa.config";
import { isServer, getHistory } from "../utils/ssr/misc";

ReactDOM.render(
<Router>
<ErrorBoundary>
<Helmet title={SPAs.appTitle} />
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
<h2>Welcome to {SPAs.appTitle}</h2>
</div>
<Switch>
<Route exact path="/" component={Overview} />
<Route path="/a" component={ComponentA} />
<Route path="/b" component={ComponentB} />
<Route path="/namelookup" component={NameLookup} />
<Route component={Overview} />
</Switch>
</ErrorBoundary>
</Router>,
document.getElementById("react-root")
);
const First: React.FC = _props => {
return (
<>
<Router history={getHistory()}>
<ErrorBoundary>
<Helmet title={SPAs.appTitle} />
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
<h2>Welcome to {SPAs.appTitle}</h2>
</div>
<Switch>
<Route exact path="/" component={Overview} />
<Route path="/a" component={ComponentA} />
<Route path="/b" component={ComponentB} />
<Route path="/namelookup" component={NameLookup} />
<Route component={Overview} />
</Switch>
</ErrorBoundary>
</Router>
</>
)
};

if (!isServer()) {
ReactDOM.render( // .render(...) is used without SSR
// ReactDOM.hydrate( // .hydrate(...) is used with SSR
<First />,
document.getElementById("react-root")
);
}

/****************** SSR block start ******************/
/*
const asString = () => {
return renderToString(<First />)
}
export default asString;
*/
/****************** SSR block end ******************/
52 changes: 41 additions & 11 deletions client/src/entrypoints/second.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,51 @@
/**
* In webpack terminology the 'entry point'
* The 'entry point' (in webpack terminology)
* of the Second SPA.
*
* SSR has been enabled for this entry point.
* To disable SSR:
* - comment out import of renderToString
* - replace ReactDOM.hydrate with ReactDOM.render (see comments below),
* - comment out the SSR block at the bottom.
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Helmet } from "react-helmet";
import { ComponentC } from "../components/ComponentC";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { renderToString } from "react-dom/server";
import * as SPAs from "../../config/spa.config";
import { isServer } from "../utils/ssr/misc";

const Second: React.FC = _props => {
return (
<>
<ErrorBoundary>
<Helmet title={SPAs.appTitle} />
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
<h2>Welcome to {SPAs.appTitle}</h2>
</div>
<ComponentC />
</ErrorBoundary>
</>
)
};

if (!isServer()) {
// ReactDOM.render( // .render(...) is used without SSR
ReactDOM.hydrate( // .hydrate(...) is used with SSR
<Second />,
document.getElementById("react-root")
);
}

/****************** SSR block start ******************/

const asString = () => {
return renderToString(<Second />)
}

export default asString;

/****************** SSR block end ******************/

ReactDOM.render(
<ErrorBoundary>
<Helmet title={SPAs.appTitle} />
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
<h2>Welcome to {SPAs.appTitle}</h2>
</div>
<ComponentC />
</ErrorBoundary>,
document.getElementById("react-root")
);
40 changes: 40 additions & 0 deletions client/src/utils/ssr/buildTimeSSR.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as fs from "fs";
import { promisify } from "util";
import { postProcess } from "./postProcess";

export async function renderToString() {
type SSRTuple = [string, () => string];
type SSRArray = Array<SSRTuple>;

const ar: SSRArray = new Array();

const getEntrypoints = require("../../../config/spa.config").getEntrypoints;

for (const [key, value] of Object.entries(getEntrypoints())) {
const ssrFileName = `${key}-SSR.txt`;
const entryPointPath = (value as string).replace(/^\.\/src/, "../..").replace(/\.\w+$/, "");
const { default: renderAsString } = await import(entryPointPath);
!!renderAsString && ar.push([ssrFileName, renderAsString] as SSRTuple);
}

const writeFile = promisify(fs.writeFile);

try {
await Promise.all(ar.map(entry => {
return writeFile('./dist/' + entry[0], entry[1]());
}));
await postProcess();
} catch (e) {
// Using console at build time is acceptable.
// tslint:disable-next-line:no-console
console.error(`Failed to create pre-built SSR file, exception: ${e}`);
process.exit(1);
}
};

renderToString().catch(e => {
// Using console at build time is acceptable.
// tslint:disable-next-line:no-console
console.error(`SSR processing failed, error: ${e}`);
process.exit(2);
});
15 changes: 15 additions & 0 deletions client/src/utils/ssr/misc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createMemoryHistory, createBrowserHistory } from "history";

export const isServer = () => {
return typeof window === 'undefined'
}

// https://stackoverflow.com/a/51511967/12005425
export const getHistory = (url = '/') => {
const history = isServer() ?
createMemoryHistory({
initialEntries: [url]
}) : createBrowserHistory();

return history;
}
57 changes: 57 additions & 0 deletions client/src/utils/ssr/postProcess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';

const workDir = './dist/';

export async function postProcess(): Promise<void> {
const readdir = promisify(fs.readdir);
const files = await readdir(workDir);
const txtFiles = files.filter(file => path.extname(file) === '.txt');
const htmlFiles = files.filter(file => path.extname(file) === '.html');
const ar = new Array<[string, string]>();

htmlFiles.forEach(file => {
const fileFound = txtFiles.find(txt => txt.startsWith(file.replace(/\.[^/.]+$/, "")));
if (fileFound) {
ar.push([file, fileFound]);
}
});

await Promise.all(ar.map(([k, v]) => {
return postProcessFile(k, v);
}));

// Using console at build time is acceptable.
// tslint:disable-next-line:no-console
console.log("Finished SSR post-processing")
}

async function postProcessFile(htmlFile: string, ssrFile: string): Promise<void> {
const readFile = promisify(fs.readFile);
const htmlFilePath = path.join(workDir, htmlFile);
const ssrFilePath = path.join(workDir, ssrFile);

const dataHtml = await readFile(htmlFilePath);
const dataSsr = (await readFile(ssrFilePath)).toString();
const reReact = /^\s*<div\s+id="react-root">/;
const ar: string[] = dataHtml.toString().replace(/\r\n?/g, '\n').split('\n');

const out = ar.map(str => {
if (reReact.test(str)) {
str += '\n';
str += dataSsr;
}
str += '\n';
return str;
});

const stream = fs.createWriteStream(htmlFilePath);
stream.on('error', err => {
// Using console at build time is acceptable.
// tslint:disable-next-line:no-console
console.error(`Failed to write to file ${htmlFilePath}, error: ${err}`)
});
out.forEach(str => { stream.write(str); });
stream.end();
}
Loading

0 comments on commit 1f9f656

Please sign in to comment.