Skip to content

Commit 4e55505

Browse files
committed
Client: Support any number of SPAs with SSR enabled
1 parent f5a16f9 commit 4e55505

File tree

7 files changed

+114
-95
lines changed

7 files changed

+114
-95
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

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

33-
* SSR. Build-time SSR (also known as prerendering) is supported. The solution allows to selectively turn the SSR on or off for the chosen part (e.g. a particular SPA) 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.
33+
* SSR. Build-time SSR (also known as prerendering or static generation) is supported. The solution allows to selectively turn the SSR on or off for the chosen parts (e.g. particular 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.
3434

3535
The SSR related costs depend on:
3636

@@ -42,7 +42,7 @@
4242

4343
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.
4444

45-
Only the SPA landing page is prerendered. Other SPA pages are not prerendered because these are the internal SPA pages. Switch to the internal pages is performed by SPA without spending time on network trips and hitting the webserver thus lowering its workload and letting it serve more clients simultaneously.
45+
Only the landing page of an SPA is prerendered. Other SPA pages are not because those are the internal SPA pages. Switching to the internal pages is performed by SPA without spending time on network trips and hitting the webserver thus lowering its workload and letting it serve more clients simultaneously.
4646

4747
* Overall simplicity. For any starter project or boilerplate, the probability of having bugs/issues down the track increases along with the amount of code. It is shown by the code size badge and can be checked for any GitHub repository using the link: `https://img.shields.io/github/languages/code-size/<user-name>/<repo-name>`. For Crisp React, the React client and the Express backend each contribute ~50% of the codebase.<br/>The code size of other starter projects was a main motivation to develop this solution. The other projects were enjoyable for learning purposes however the amount of code was percieved to be excessive for use in production.
4848

client/.vscode/launch.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"url": "http://localhost:3000/first.html",
1212
"sourceMaps": true,
1313
"preLaunchTask": "npm build:prod-debug",
14-
"trace": "verbose",
14+
"trace": true,
1515
"webRoot": "${workspaceFolder}",
1616
"breakOnLoad": true,
1717
"disableNetworkCache": true,
@@ -24,7 +24,7 @@
2424
"sourceMaps": true,
2525
"preLaunchTask": "npm dev",
2626
"postDebugTask": "kill process in terminal",
27-
"trace": "verbose",
27+
"trace": true,
2828
"webRoot": "${workspaceFolder}",
2929
"breakOnLoad": true,
3030
"disableNetworkCache": true,
@@ -37,7 +37,7 @@
3737
"sourceMaps": true,
3838
"preLaunchTask": "npm dev",
3939
"postDebugTask": "kill process in terminal",
40-
"trace": "verbose",
40+
"trace": true,
4141
"webRoot": "${workspaceFolder}",
4242
"breakOnLoad": true,
4343
"disableNetworkCache": true,

client/config/spa.config.js

+24-17
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,25 @@ Each SPA has:
1515
- SSR flag. If set to true, the SPA landing page will be prerendered at the
1616
build time e.g. its HTML will be generated and inserted into the <body> of
1717
the .html file.
18-
Only one SPA can have this flag set. When the flag is not set, the <body>
18+
Any SPA can have this flag set. When the flag is not set, the <body>
1919
contains mainly references to the script bundles with very little HTML markup.
2020
2121
You can customize SPAs by modifying the SPA Configuration block below. It will
22-
reconfigure client, backend and the tests. You'll need to adjust 3 lines
23-
"http://localhost:<port>/first.html" in the ../.vscode/launch.json file.
24-
If the first SPA is called 'login' then change these lines to:
25-
"http://localhost:<port>/login.html".
22+
reconfigure client, backend and the tests.
2623
2724
Note: Page transitions within an SPA are performed as usual using <Link>,
2825
<NavLink> and other means (like history.push if enabled) customary to all
2926
SPAs. Transitions from one SPA to another should be performed using HTML
3027
anchor element (or its replacement provided by the UI library) targeting the
31-
other SPA landing page:
28+
other SPA landing page. For example:
3229
<a href="/first.html"> or <Menu.Item href="/second.html">.
3330
31+
Note: To facilitate debugging, edit the ../.vscode/launch.json file to reflect
32+
the SPA Configuration block changes. You'll need to adjust the three lines
33+
with the text "http://localhost:<port>/first.html".
34+
For example, if you renamed the first SPA from 'first' to 'login' then
35+
change these three lines to: "http://localhost:<port>/login.html".
36+
3437
To turn off code splitting using multiple SPAs simply leave one SPA in the
3538
SPA Configuration block.
3639
*/
@@ -76,13 +79,16 @@ var ConfiguredSPAs = function() {
7679
throw new RangeError("One and only one SPA must have 'redirect: true'");
7780
}
7881

79-
num = SPAs.reduce(function(acc, item) {
80-
return item.params.ssr ? acc + 1 : acc;
81-
}, 0);
82-
83-
if (num > 1) {
84-
throw new RangeError("At most one SPA can have 'ssr: true'");
85-
}
82+
SPAs.forEach(function(spa) {
83+
var spaName = spa.params.name.toLowerCase();
84+
var spas = SPAs.filter(function(item) {
85+
return item.params.name.toLowerCase().startsWith(spaName);
86+
});
87+
88+
if (spas.length !== 1) {
89+
throw new RangeError("SPAs have names that are not distinct enough");
90+
}
91+
});
8692
};
8793

8894
SPAs.getEntrypoints = function() {
@@ -99,12 +105,13 @@ var ConfiguredSPAs = function() {
99105
}).params.name;
100106
};
101107

102-
SPAs.getSsrName = function() {
103-
var spa = SPAs.find(function(spa) {
108+
SPAs.getSsrNames = function() {
109+
var spas = SPAs.filter(function(spa) {
104110
return spa.params.ssr;
105111
});
106-
// eslint-disable-next-line
107-
return !!spa? spa.params.name : undefined;
112+
return spas.map(function(spa) {
113+
return spa.params.name;
114+
});
108115
};
109116

110117
SPAs.getNames = function() {

client/src/utils/postprocess/postProcess.ts

+71-51
Original file line numberDiff line numberDiff line change
@@ -7,73 +7,93 @@ import { postProcess as postProcessCSS } from "./postProcessCSS";
77

88
const workDir = "./dist/";
99

10-
export async function postProcess(): Promise<void> {
11-
const ssrSpaName = require("../../../config/spa.config").getSsrName();
12-
13-
if (ssrSpaName) {
14-
15-
type SSRTuple = [string, string];
16-
let tp: SSRTuple|undefined;
10+
// stackoverflow.com/a/16060619
11+
function requireUncached(module: string) {
12+
delete require.cache[require.resolve(module)];
13+
return require(module);
14+
}
1715

18-
console.log("Starting SSR post-processing");
16+
export async function postProcess(): Promise<void> {
17+
const ssrSpaNames = require("../../../config/spa.config").getSsrNames();
1918

19+
if (ssrSpaNames.length > 0) {
2020
const getEntrypoints = require("../../../config/spa.config").getEntrypoints;
2121

22-
for (const [key, value] of Object.entries(getEntrypoints())) {
23-
if (key === ssrSpaName) {
24-
const ssrFileName = `${key}-SSR.txt`;
25-
tp = [ssrFileName, value as string];
26-
break;
22+
const performSsr = async (ssrSpaName: any) => {
23+
type SSRTuple = [string, string];
24+
let tp: SSRTuple|undefined;
25+
26+
for (const [key, value] of Object.entries(getEntrypoints())) {
27+
if (key === ssrSpaName) {
28+
const ssrFileName = `${key}-SSR.txt`;
29+
tp = [ssrFileName, value as string];
30+
break;
31+
}
2732
}
28-
}
2933

30-
if (!tp) {
31-
console.error("Internal SSR processing error");
32-
process.exit(1);
33-
}
34+
if (!tp) {
35+
console.error("Internal SSR processing error");
36+
process.exit(1);
37+
}
3438

35-
const webpackConfig = require("../../../webpack.config.ssr.js");
36-
const compiler = webpack({...webpackConfig, entry: tp[1]});
39+
const webpackConfig = require("../../../webpack.config.ssr.js");
40+
const compiler = webpack({...webpackConfig, entry: tp[1]});
3741

38-
type PromiseCallback = () => void;
42+
type PromiseCallback = () => void;
3943

40-
class CallbackWrapper {
41-
constructor(readonly callback: PromiseCallback) {
42-
}
43-
readonly invoke = (): void => {
44-
this.callback();
44+
class CallbackWrapper {
45+
constructor(readonly callback: PromiseCallback) {
46+
}
47+
readonly invoke = (): void => {
48+
this.callback();
49+
}
4550
}
46-
}
47-
48-
let cbWrapper: CallbackWrapper|undefined;
49-
const waitForCompiler = new Promise<void>((resolve) => { cbWrapper = new CallbackWrapper(resolve)});
5051

51-
compiler.run((err, stats) => {
52-
if (err) {
53-
console.error(`Library compilation failed, error: ${err}`);
52+
let cbWrapper: CallbackWrapper|undefined;
53+
const waitForCompiler = new Promise<void>((resolve) => { cbWrapper = new CallbackWrapper(resolve)});
54+
55+
compiler.run((err, stats) => {
56+
if (err) {
57+
console.error(`Library compilation failed, error: ${err}`);
58+
process.exit(1);
59+
}
60+
if (stats?.hasErrors()) {
61+
console.error(`Library compilation failed, error: ${stats?.toString() ?? "no description"}`);
62+
process.exit(1);
63+
}
64+
cbWrapper?.invoke();
65+
});
66+
67+
await waitForCompiler;
68+
69+
const { ssrLibrary } = requireUncached("../../../dist-ssr/ssr-library");
70+
const { default: asString } = ssrLibrary;
71+
72+
if (!asString) {
73+
console.error("Error: Any SPA with SSR enabled must have 'asString' function exported as default in its entrypoint .tsx file. \
74+
Please check the 4-step sequence (provided in the comments at the top of each entrypoints/xxx.tsx file) has been followed.");
5475
process.exit(1);
5576
}
56-
if (stats?.hasErrors()) {
57-
console.error(`Library compilation failed, error: ${stats?.toString() ?? "no description"}`);
77+
78+
const writeFile = promisify(fs.writeFile);
79+
80+
try {
81+
await writeFile(path.join(workDir, tp[0]), asString());
82+
await postProcessSSR(workDir, ssrSpaName);
83+
} catch (e) {
84+
console.error(`Failed to create pre-built SSR file, exception: ${e}`);
5885
process.exit(1);
5986
}
60-
cbWrapper?.invoke();
61-
});
87+
};
6288

63-
await waitForCompiler;
64-
65-
const { ssrLibrary } = require("../../../dist-ssr/ssr-library");
66-
const { default: asString } = ssrLibrary;
67-
const writeFile = promisify(fs.writeFile);
89+
console.log("Starting SSR post-processing");
6890

69-
try {
70-
await writeFile(path.join(workDir, tp[0]), asString());
71-
await postProcessSSR();
72-
} catch (e) {
73-
console.error(`Failed to create pre-built SSR file, exception: ${e}`);
74-
process.exit(1);
91+
for (const spa of ssrSpaNames) {
92+
await performSsr(spa);
7593
}
76-
} //if (ssrSpaName)
94+
95+
console.log("Finished SSR post-processing")
96+
}
7797

7898
if (process.env.CF_PAGES) {
7999
const writeFile = promisify(fs.writeFile);
@@ -95,11 +115,11 @@ export async function postProcess(): Promise<void> {
95115
await postProcessCSS();
96116
} catch (e) {
97117
console.error(`Failed to post-process CSS, exception: ${e}`);
98-
process.exit(1);
118+
process.exit(2);
99119
}
100120
}
101121

102122
postProcess().catch((e: Error) => {
103123
console.error(`SSR/CSS processing failed, error: ${e}`);
104-
process.exit(2);
124+
process.exit(3);
105125
});

client/src/utils/postprocess/postProcessSSR.ts

+10-18
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,22 @@ import * as fs from "fs";
22
import * as path from "path";
33
import { promisify } from "util";
44

5-
const workDir = "./dist/";
6-
7-
export async function postProcess(): Promise<void> {
5+
export async function postProcess(workDir: string, filePattern: string): Promise<void> {
86
const readdir = promisify(fs.readdir);
97
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-
});
8+
const txtFiles = files.filter(file =>
9+
path.extname(file) === ".txt" && path.basename(file).startsWith(filePattern));
10+
const htmlFiles = files.filter(file =>
11+
path.extname(file) === ".html" && path.basename(file).startsWith(filePattern));
2012

21-
await Promise.all(ar.map(([k, v]) => {
22-
return postProcessFile(k, v);
23-
}));
13+
if (txtFiles.length !== 1 || htmlFiles.length !== 1) {
14+
throw new Error("Unexpected count of SSR related files");
15+
}
2416

25-
console.log("Finished SSR post-processing")
17+
await postProcessFile(workDir, htmlFiles[0], txtFiles[0]);
2618
}
2719

28-
async function postProcessFile(htmlFile: string, ssrFile: string): Promise<void> {
20+
async function postProcessFile(workDir: string, htmlFile: string, ssrFile: string): Promise<void> {
2921
const readFile = promisify(fs.readFile);
3022
const htmlFilePath = path.join(workDir, htmlFile);
3123
const ssrFilePath = path.join(workDir, ssrFile);

client/webpack.config.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
const path = require("path");
22
const fs = require("fs");
33
const webpack = require("webpack")
4-
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
54
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
65
const HtmlWebpackPlugin = require("html-webpack-plugin");
76
const HtmlWebpackHarddiskPlugin = require("html-webpack-harddisk-plugin");
@@ -127,6 +126,8 @@ const getWebpackConfig = (env, argv) => {
127126
path: path.resolve(__dirname, "dist"),
128127
publicPath: isJamstack? "/" : "/static/",
129128
crossOriginLoading: "anonymous",
129+
clean: true,
130+
compareBeforeEmit: false,
130131
},
131132
optimization: {
132133
splitChunks: {
@@ -169,7 +170,6 @@ const getWebpackConfig = (env, argv) => {
169170
}),
170171
},
171172
plugins: [
172-
new CleanWebpackPlugin(),
173173
new webpack.DefinePlugin({
174174
"process.env.DEVELOPMENT": JSON.stringify(isProduction === false),
175175
"CF_PAGES": !!process.env.CF_PAGES,

client/webpack.config.ssr.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
const path = require("path");
22
const webpack = require("webpack")
33
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
4-
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
54
const nodeExternals = require("webpack-node-externals");
65
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
76
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
@@ -80,7 +79,6 @@ module.exports = {
8079
]
8180
},
8281
plugins: [
83-
new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ["dist-ssr"]}),
8482
new webpack.DefinePlugin({
8583
"process.env.DEVELOPMENT": JSON.stringify(isProduction === false),
8684
"CF_PAGES": !!process.env.CF_PAGES,
@@ -101,6 +99,8 @@ module.exports = {
10199
filename: "ssr-library.js",
102100
library: "ssrLibrary",
103101
libraryTarget: "commonjs",
102+
clean: true,
103+
compareBeforeEmit: false,
104104
},
105105
externals: [nodeExternals()],
106106
};

0 commit comments

Comments
 (0)