Skip to content

Commit 1f9f656

Browse files
committed
Merge branch 'client/ssr'
2 parents 27f62a3 + 231fb60 commit 1f9f656

File tree

11 files changed

+314
-41
lines changed

11 files changed

+314
-41
lines changed

README.md

+22-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
* 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).
2828

29-
* 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.
29+
* 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.
3030

3131
* 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.
3232

@@ -38,6 +38,18 @@
3838
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/>
3939
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.
4040

41+
* 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.
42+
43+
The SSR related costs depend on:
44+
45+
- 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.
46+
47+
- 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.
48+
49+
- 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.
50+
51+
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.
52+
4153
* 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.
4254

4355
- 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/>
@@ -57,6 +69,7 @@ It can be conveniently executed from the Cloud Shell session opened during the d
5769
- [Usage](#usage)
5870
- [Client Usage Scenarios](#client-usage-scenarios)
5971
- [Backend Usage Scenarios](#backend-usage-scenarios)
72+
- [SSR](#ssr)
6073
- [Containerisation](#containerisation)
6174
- [What's Next](#whats-next)
6275
- [Pitfall Avoidance](#pitfall-avoidance)
@@ -315,6 +328,11 @@ Edit file `client/webpack.config.js` to change the `sourceMap` setting of the Te
315328
Start the debugging configuration `Debug Production Client and Backend (workspace)`.<br/>
316329
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/>
317330
To finish stop the running debugging configuration (use the Debugging toolbar or press `Control+F5` once).
331+
## SSR
332+
### Turning On and Off on the Application Level
333+
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.
334+
### Turning On and Off on the SPA Level
335+
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.
318336
## Containerisation
319337
### Using Docker
320338
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.
@@ -398,6 +416,9 @@ A: Open the Settings page of the Chrome DevTools and ensure 'Enable JavaScript s
398416
Q: Breakpoints in VS Code are not hit. How can it be fixed.<br/>
399417
A: Try to remove the breakpoint and set it again. If the breakpoint is in the client code, refresh the page.
400418

419+
Q: I need to add Redux.<br/>
420+
A: Have a look at the sibling Crisp BigQuery repository created by cloning and renaming this solution. It uses Redux.
421+
401422
Q: Linting the client and the backend yields a couple of errors. How do I fix it?<br/>
402423
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.
403424
## License

client/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"scripts": {
2828
"build": "webpack",
2929
"build:prod": "webpack --env.prod",
30+
"postbuild:prod": "cross-env TS_NODE_PROJECT=tsconfig.ssr.json node -r ts-node/register -r tsconfig-paths/register src/utils/ssr/buildTimeSSR.ts",
3031
"compile": "tsc -p .",
3132
"lint": "tslint -p .",
3233
"dev": "webpack-dev-server --config webpack.config.js",
@@ -73,6 +74,8 @@
7374
"style-loader": "1.1.3",
7475
"ts-jest": "^25.2.0",
7576
"ts-loader": "6.2.1",
77+
"ts-node": "^8.6.2",
78+
"tsconfig-paths": "^3.9.0",
7679
"tsconfig-paths-webpack-plugin": "^3.2.0",
7780
"tslib": "1.10.0",
7881
"tslint": "6.0.0",

client/src/components/ErrorBoundary.tsx

+13-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as ReactDOM from "react-dom";
99
import { style } from "typestyle";
1010
import { isCustomError } from "../utils/typeguards";
1111
import logger from "../utils/logger";
12+
import { isServer } from "../utils/ssr/misc";
1213

1314
type ErrorBoundaryState = {
1415
hasError: boolean
@@ -31,7 +32,7 @@ export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
3132
const errMsg = this.state.errDescription + "\n" + errInfo.componentStack;
3233
logger.error(errMsg);
3334
this.setState(prevState =>
34-
({ ...prevState, errDescription: errMsg })
35+
({ ...prevState, errDescription: errMsg })
3536
);
3637
}
3738
}
@@ -44,13 +45,17 @@ export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
4445

4546
public render() {
4647
if (this.state.hasError) {
47-
return (
48-
<PortalCreator
49-
onClose={this.onClick}
50-
errorHeader="Error"
51-
errorText={this.state.errDescription!}
52-
/>
53-
);
48+
return isServer() ?
49+
// Using console at build time is acceptable.
50+
// tslint:disable-next-line:no-console
51+
(console.error(this.state.errDescription!), <>{`SSR Error: ${this.state.errDescription!}`}</>) :
52+
(
53+
<PortalCreator
54+
onClose={this.onClick}
55+
errorHeader="Error"
56+
errorText={this.state.errDescription!}
57+
/>
58+
);
5459
} else {
5560
return this.props.children;
5661
}

client/src/entrypoints/first.tsx

+49-20
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,62 @@
11
/**
2-
* In webpack terminology the 'entry point'
2+
* The 'entry point' (in webpack terminology)
33
* of the First SPA.
4+
*
5+
* SSR has been disabled for this entry point.
6+
* To enable SSR:
7+
* - uncomment import of renderToString
8+
* - replace ReactDOM.render with ReactDOM.hydrate (see comments below),
9+
* - uncomment the SSR block at the bottom.
410
*/
511
import * as React from "react";
612
import * as ReactDOM from "react-dom";
713
import { Helmet } from "react-helmet";
8-
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
14+
import { Router, Route, Switch } from "react-router-dom";
915
import { ComponentA } from "../components/ComponentA";
1016
import { ComponentB } from "../components/ComponentB";
1117
import { Overview } from "../components/Overview";
1218
import { NameLookup } from "../components/NameLookup";
1319
import { ErrorBoundary } from "../components/ErrorBoundary";
20+
// import { renderToString } from "react-dom/server"; // used for SSR
1421
import * as SPAs from "../../config/spa.config";
22+
import { isServer, getHistory } from "../utils/ssr/misc";
1523

16-
ReactDOM.render(
17-
<Router>
18-
<ErrorBoundary>
19-
<Helmet title={SPAs.appTitle} />
20-
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
21-
<h2>Welcome to {SPAs.appTitle}</h2>
22-
</div>
23-
<Switch>
24-
<Route exact path="/" component={Overview} />
25-
<Route path="/a" component={ComponentA} />
26-
<Route path="/b" component={ComponentB} />
27-
<Route path="/namelookup" component={NameLookup} />
28-
<Route component={Overview} />
29-
</Switch>
30-
</ErrorBoundary>
31-
</Router>,
32-
document.getElementById("react-root")
33-
);
24+
const First: React.FC = _props => {
25+
return (
26+
<>
27+
<Router history={getHistory()}>
28+
<ErrorBoundary>
29+
<Helmet title={SPAs.appTitle} />
30+
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
31+
<h2>Welcome to {SPAs.appTitle}</h2>
32+
</div>
33+
<Switch>
34+
<Route exact path="/" component={Overview} />
35+
<Route path="/a" component={ComponentA} />
36+
<Route path="/b" component={ComponentB} />
37+
<Route path="/namelookup" component={NameLookup} />
38+
<Route component={Overview} />
39+
</Switch>
40+
</ErrorBoundary>
41+
</Router>
42+
</>
43+
)
44+
};
45+
46+
if (!isServer()) {
47+
ReactDOM.render( // .render(...) is used without SSR
48+
// ReactDOM.hydrate( // .hydrate(...) is used with SSR
49+
<First />,
50+
document.getElementById("react-root")
51+
);
52+
}
53+
54+
/****************** SSR block start ******************/
55+
/*
56+
const asString = () => {
57+
return renderToString(<First />)
58+
}
59+
60+
export default asString;
61+
*/
62+
/****************** SSR block end ******************/

client/src/entrypoints/second.tsx

+41-11
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,51 @@
11
/**
2-
* In webpack terminology the 'entry point'
2+
* The 'entry point' (in webpack terminology)
33
* of the Second SPA.
4+
*
5+
* SSR has been enabled for this entry point.
6+
* To disable SSR:
7+
* - comment out import of renderToString
8+
* - replace ReactDOM.hydrate with ReactDOM.render (see comments below),
9+
* - comment out the SSR block at the bottom.
410
*/
511
import * as React from "react";
612
import * as ReactDOM from "react-dom";
713
import { Helmet } from "react-helmet";
814
import { ComponentC } from "../components/ComponentC";
915
import { ErrorBoundary } from "../components/ErrorBoundary";
16+
import { renderToString } from "react-dom/server";
1017
import * as SPAs from "../../config/spa.config";
18+
import { isServer } from "../utils/ssr/misc";
19+
20+
const Second: React.FC = _props => {
21+
return (
22+
<>
23+
<ErrorBoundary>
24+
<Helmet title={SPAs.appTitle} />
25+
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
26+
<h2>Welcome to {SPAs.appTitle}</h2>
27+
</div>
28+
<ComponentC />
29+
</ErrorBoundary>
30+
</>
31+
)
32+
};
33+
34+
if (!isServer()) {
35+
// ReactDOM.render( // .render(...) is used without SSR
36+
ReactDOM.hydrate( // .hydrate(...) is used with SSR
37+
<Second />,
38+
document.getElementById("react-root")
39+
);
40+
}
41+
42+
/****************** SSR block start ******************/
43+
44+
const asString = () => {
45+
return renderToString(<Second />)
46+
}
47+
48+
export default asString;
49+
50+
/****************** SSR block end ******************/
1151

12-
ReactDOM.render(
13-
<ErrorBoundary>
14-
<Helmet title={SPAs.appTitle} />
15-
<div style={{ textAlign: "center", marginTop: "2rem", marginBottom: "3rem" }}>
16-
<h2>Welcome to {SPAs.appTitle}</h2>
17-
</div>
18-
<ComponentC />
19-
</ErrorBoundary>,
20-
document.getElementById("react-root")
21-
);

client/src/utils/ssr/buildTimeSSR.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as fs from "fs";
2+
import { promisify } from "util";
3+
import { postProcess } from "./postProcess";
4+
5+
export async function renderToString() {
6+
type SSRTuple = [string, () => string];
7+
type SSRArray = Array<SSRTuple>;
8+
9+
const ar: SSRArray = new Array();
10+
11+
const getEntrypoints = require("../../../config/spa.config").getEntrypoints;
12+
13+
for (const [key, value] of Object.entries(getEntrypoints())) {
14+
const ssrFileName = `${key}-SSR.txt`;
15+
const entryPointPath = (value as string).replace(/^\.\/src/, "../..").replace(/\.\w+$/, "");
16+
const { default: renderAsString } = await import(entryPointPath);
17+
!!renderAsString && ar.push([ssrFileName, renderAsString] as SSRTuple);
18+
}
19+
20+
const writeFile = promisify(fs.writeFile);
21+
22+
try {
23+
await Promise.all(ar.map(entry => {
24+
return writeFile('./dist/' + entry[0], entry[1]());
25+
}));
26+
await postProcess();
27+
} catch (e) {
28+
// Using console at build time is acceptable.
29+
// tslint:disable-next-line:no-console
30+
console.error(`Failed to create pre-built SSR file, exception: ${e}`);
31+
process.exit(1);
32+
}
33+
};
34+
35+
renderToString().catch(e => {
36+
// Using console at build time is acceptable.
37+
// tslint:disable-next-line:no-console
38+
console.error(`SSR processing failed, error: ${e}`);
39+
process.exit(2);
40+
});

client/src/utils/ssr/misc.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createMemoryHistory, createBrowserHistory } from "history";
2+
3+
export const isServer = () => {
4+
return typeof window === 'undefined'
5+
}
6+
7+
// https://stackoverflow.com/a/51511967/12005425
8+
export const getHistory = (url = '/') => {
9+
const history = isServer() ?
10+
createMemoryHistory({
11+
initialEntries: [url]
12+
}) : createBrowserHistory();
13+
14+
return history;
15+
}

client/src/utils/ssr/postProcess.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { promisify } from 'util';
4+
5+
const workDir = './dist/';
6+
7+
export async function postProcess(): Promise<void> {
8+
const readdir = promisify(fs.readdir);
9+
const files = await readdir(workDir);
10+
const txtFiles = files.filter(file => path.extname(file) === '.txt');
11+
const htmlFiles = files.filter(file => path.extname(file) === '.html');
12+
const ar = new Array<[string, string]>();
13+
14+
htmlFiles.forEach(file => {
15+
const fileFound = txtFiles.find(txt => txt.startsWith(file.replace(/\.[^/.]+$/, "")));
16+
if (fileFound) {
17+
ar.push([file, fileFound]);
18+
}
19+
});
20+
21+
await Promise.all(ar.map(([k, v]) => {
22+
return postProcessFile(k, v);
23+
}));
24+
25+
// Using console at build time is acceptable.
26+
// tslint:disable-next-line:no-console
27+
console.log("Finished SSR post-processing")
28+
}
29+
30+
async function postProcessFile(htmlFile: string, ssrFile: string): Promise<void> {
31+
const readFile = promisify(fs.readFile);
32+
const htmlFilePath = path.join(workDir, htmlFile);
33+
const ssrFilePath = path.join(workDir, ssrFile);
34+
35+
const dataHtml = await readFile(htmlFilePath);
36+
const dataSsr = (await readFile(ssrFilePath)).toString();
37+
const reReact = /^\s*<div\s+id="react-root">/;
38+
const ar: string[] = dataHtml.toString().replace(/\r\n?/g, '\n').split('\n');
39+
40+
const out = ar.map(str => {
41+
if (reReact.test(str)) {
42+
str += '\n';
43+
str += dataSsr;
44+
}
45+
str += '\n';
46+
return str;
47+
});
48+
49+
const stream = fs.createWriteStream(htmlFilePath);
50+
stream.on('error', err => {
51+
// Using console at build time is acceptable.
52+
// tslint:disable-next-line:no-console
53+
console.error(`Failed to write to file ${htmlFilePath}, error: ${err}`)
54+
});
55+
out.forEach(str => { stream.write(str); });
56+
stream.end();
57+
}

0 commit comments

Comments
 (0)