├── .babelrc
├── .circleci
└── config.yml
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── build.gradle
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── package-lock.json
├── package.json
├── public
├── images
│ ├── java-server-side-render.jpg
│ ├── server-side-render.jpg
│ └── ssr-static-image-server-side-rendering.jpg
└── index.html
├── react-src
├── api
│ └── postcode.js
├── client.js
├── components
│ ├── app.js
│ ├── dynamic.js
│ ├── footer.js
│ ├── home.js
│ ├── nav.js
│ ├── search.js
│ └── static.js
├── index.scss
└── server.js
├── src
└── main
│ └── java
│ └── com
│ └── djh
│ ├── Application.java
│ ├── SSRConfiguration.java
│ ├── SSRController.java
│ └── postcode
│ ├── Postcode.java
│ ├── PostcodeAPIClient.java
│ ├── PostcodeConfiguration.java
│ ├── PostcodeController.java
│ └── PostcodeService.java
├── webpack.config.dev.babel.js
├── webpack.config.prod.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ],
6 | "plugins": [
7 | "@babel/plugin-proposal-object-rest-spread"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | working_directory: ~/react-ssr-sample-java-graalvm
5 | docker:
6 | - image: circleci/openjdk:11.0-jdk-node-browsers
7 |
8 | steps:
9 | - checkout
10 |
11 | - restore_cache:
12 | key: frontend-deps-{{ checksum "package.json" }}
13 | - restore_cache:
14 | key: backend-deps-{{ checksum "build.gradle" }}
15 |
16 | - run:
17 | name: Install Frontend
18 | command: yarn install
19 |
20 | - run:
21 | name: Build Frontend
22 | command: yarn build
23 |
24 | - run:
25 | name: Build Backend
26 | command: ./gradlew build
27 |
28 | - save_cache:
29 | key: frontend-deps-{{ checksum "package.json" }}
30 | paths:
31 | - ./node_modules
32 | - save_cache:
33 | key: backend-deps-{{ checksum "build.gradle" }}
34 | paths:
35 | - ~/.gradle
36 |
37 | - persist_to_workspace:
38 | root: .
39 | paths:
40 | - 'Dockerfile'
41 | - 'build/libs/app.jar'
42 |
43 | deploy:
44 | working_directory: ~/react-ssr-sample-java-graalvm
45 | machine: true
46 |
47 | steps:
48 | - attach_workspace:
49 | at: ~/react-ssr-sample-java-graalvm
50 |
51 | - run:
52 | name: Docker Build
53 | command: docker build -t daves125125/react-ssr-sample-java-graalvm .
54 |
55 | - run:
56 | name: Docker Login
57 | command: docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
58 |
59 | - run:
60 | name: Docker Push
61 | command: docker push daves125125/react-ssr-sample-java-graalvm
62 |
63 | workflows:
64 | version: 2
65 | build_and_deploy:
66 | jobs:
67 | - build
68 | - deploy:
69 | requires:
70 | - build
71 | filters:
72 | branches:
73 | only: master
74 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | *
2 | !build/libs/app.jar
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 |
3 | react-build
4 | src/main/resources/public
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM openjdk:11-jre-slim
2 |
3 | COPY ./build/libs/app.jar /app/dist/app.jar
4 | WORKDIR /app/dist
5 |
6 | EXPOSE 8080
7 |
8 | ENTRYPOINT exec java \
9 | -Xms150m \
10 | -Xmx150m \
11 | -XX:MetaspaceSize=200m \
12 | -XX:MaxMetaspaceSize=200m \
13 | -Xss512k \
14 | -jar app.jar
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-ssr-sample-java-graalvm
2 |
3 | This project demonstrates rendering Javascript code on the server side using Java with GraalVM.
4 |
5 |
6 | ## TL;DR
7 |
8 | The client side code consists of a small React app that uses some popular libraries such as react-router, bootstrap etc. It
9 | features a page that has dynamic data with state inserted from the server side which can then also be later updated on the client side.
10 |
11 | The server side code consists of a simple Spring Boot application that renders JS using GraalVM. The server side app fetches and caches
12 | some basic postcode data available from a third party, open API - https://api.postcodes.io.
13 |
14 | See the Run section on how to start the app locally.
15 |
16 |
17 | ## Run
18 |
19 | This sample has been packaged as a docker container and can be ran by executing:
20 |
21 | ```
22 | docker run -p8080:8080 daves125125/react-ssr-sample-java-graalvm
23 | ```
24 |
25 | Navigate to `localhost:8080/` to see the sample running.
26 |
27 |
28 | ## Build / Run from source
29 |
30 | ```
31 | yarn install && yarn build && ./gradlew clean bootRun
32 | ```
33 |
34 | Or, via Docker:
35 |
36 | ```
37 | yarn install && yarn build
38 | ./gradlew clean build && docker build -t test .
39 | docker run -p8080:8080 test
40 | ```
41 |
42 |
43 | ## How this works / Areas of interest
44 |
45 | NOTE: Refer to the build.gradle file for a Spring Boot package Gotcha.
46 |
47 |
48 | The JS code is split into two main bundles, the client.js and server.js. These are built as independent source sets
49 | by Webpack. Both the server.js and client.js depend upon the the main React App itself with the only difference being
50 | that the client side component includes client side specific code such as browser routing, and the server side code includes
51 | server side routing and injection of initial state.
52 |
53 | The Server side uses graalvm to render only the server.js bundle which gets packaged as part of the build process. Some
54 | Spring framework code largely abstracts the handling of the MVC layer as well as the configuration of the graalvm templating
55 | engine.
56 |
57 | Regarding SSR, the main files of interest are:
58 |
59 | - react-src/client.js
60 | - react-src/server.js
61 | - src/main/java/com/djh/SSRController.java
62 | - src/main/java/com/djh/SSRConfiguration.java
63 |
64 |
65 | ## Performance
66 |
67 | The below have been collected from repeated runs using the AB testing tool. This has been ran on a MacBook Pro (Retina, 13-inch, Early 2015)
68 |
69 | | | At Rest / Startup | Under Load |
70 | | ------------------- |:-----------------------------------:| -----------:|
71 | | Render Speed (ms) | ~32 | ~15 |
72 | | Throughput (msgs/s) | ~30 | ~60 |
73 | | Memory Usage (Mb) | ~300 | ~300 |
74 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'org.springframework.boot' version '2.1.2.RELEASE'
3 | }
4 |
5 | apply plugin: 'java'
6 | apply plugin: 'io.spring.dependency-management'
7 |
8 | sourceCompatibility = 11
9 | targetCompatibility = 11
10 |
11 | repositories {
12 | jcenter()
13 | }
14 |
15 | // NOTE: This is really important, some graalvm runtime libs need to be 'unpacked' when bundled by Spring Boot in order
16 | // for the ScriptEngine classes to load properly.
17 | //
18 | // Example exception if this is not done:
19 | // ScriptEngineManager providers.next(): javax.script.ScriptEngineFactory: Provider com.oracle.truffle.js.scriptengine.GraalJSEngineFactory could not be instantiated
20 | bootJar {
21 | requiresUnpack '**/*js*.*', '**/*regex*.*'
22 | baseName = 'app'
23 | }
24 |
25 | dependencies {
26 | runtime 'org.graalvm.js:js:1.0.0-rc11'
27 | runtime 'org.graalvm.js:js-scriptengine:1.0.0-rc11'
28 | compile 'org.graalvm.sdk:graal-sdk:1.0.0-rc11'
29 |
30 | compile 'org.springframework.boot:spring-boot-starter-web'
31 | compile 'org.springframework:spring-context-support'
32 | }
33 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davehancock/react-ssr-sample-java-graalvm/ab2bf9a48545bddb158a62798427151b622143d2/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | zipStoreBase=GRADLE_USER_HOME
4 | zipStorePath=wrapper/dists
5 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-bin.zip
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-ssr-sample-java-graalvm",
3 | "version": "0.0.1",
4 | "private": true,
5 | "dependencies": {
6 | "bootstrap": "^4.1.3",
7 | "jquery": "^3.2.1",
8 | "object-assign": "4.1.1",
9 | "popper.js": "^1.12.9",
10 | "promise": "8.0.1",
11 | "raf": "3.4.0",
12 | "react": "^16.2.0",
13 | "react-dom": "^16.2.0",
14 | "react-error-overlay": "^1.0.9",
15 | "react-router-bootstrap": "^0.24.4",
16 | "react-router-dom": "^4.2.2",
17 | "reactstrap": "^5.0.0-alpha.4",
18 | "serialize-javascript": "^1.4.0"
19 | },
20 | "devDependencies": {
21 | "@babel/cli": "^7.1.0",
22 | "@babel/core": "^7.1.0",
23 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
24 | "@babel/plugin-transform-modules-commonjs": "^7.1.0",
25 | "@babel/plugin-transform-runtime": "^7.1.0",
26 | "@babel/preset-env": "^7.1.0",
27 | "@babel/preset-react": "^7.0.0",
28 | "@babel/register": "^7.0.0",
29 | "babel-core": "7.0.0-bridge.0",
30 | "babel-eslint": "^10.0.1",
31 | "babel-loader": "^8.0.2",
32 | "clean-webpack-plugin": "^1.0.0",
33 | "copy-webpack-plugin": "^4.6.0",
34 | "css-loader": "^2.1.0",
35 | "eslint": "^5.12.0",
36 | "eslint-config-airbnb": "^17.1.0",
37 | "eslint-plugin-import": "^2.14.0",
38 | "eslint-plugin-jsx-a11y": "^6.1.2",
39 | "eslint-plugin-react": "^7.12.3",
40 | "file-loader": "^3.0.1",
41 | "html-webpack-plugin": "^3.2.0",
42 | "mini-css-extract-plugin": "^0.5.0",
43 | "node-sass": "^4.11.0",
44 | "sass-loader": "^7.1.0",
45 | "style-loader": "^0.23.1",
46 | "webpack": "^4.28.4",
47 | "webpack-cli": "^3.2.1",
48 | "webpack-dev-server": "^3.1.14",
49 | "whatwg-fetch": "^3.0.0"
50 | },
51 | "scripts": {
52 | "start": "webpack-dev-server --config webpack.config.dev.babel.js",
53 | "build": "webpack -p --config webpack.config.prod.js"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/public/images/java-server-side-render.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davehancock/react-ssr-sample-java-graalvm/ab2bf9a48545bddb158a62798427151b622143d2/public/images/java-server-side-render.jpg
--------------------------------------------------------------------------------
/public/images/server-side-render.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davehancock/react-ssr-sample-java-graalvm/ab2bf9a48545bddb158a62798427151b622143d2/public/images/server-side-render.jpg
--------------------------------------------------------------------------------
/public/images/ssr-static-image-server-side-rendering.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davehancock/react-ssr-sample-java-graalvm/ab2bf9a48545bddb158a62798427151b622143d2/public/images/ssr-static-image-server-side-rendering.jpg
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | React SSR Sample Java
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | SERVER_RENDERED_HTML
14 |
15 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/react-src/api/postcode.js:
--------------------------------------------------------------------------------
1 | const POSTCODE_ENDPOINT = './postcode/';
2 |
3 | function postcodes(query) {
4 |
5 | return fetch(POSTCODE_ENDPOINT + query, {method: 'GET',})
6 | .then(response => response.json()
7 | .then(data => ({
8 | data: data,
9 | status: response.status
10 | })
11 | ));
12 | }
13 |
14 | export default postcodes
15 |
--------------------------------------------------------------------------------
/react-src/client.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {BrowserRouter} from 'react-router-dom'
4 |
5 | import App from './components/app'
6 | import './index.scss';
7 |
8 | const initialState = window.__PRELOADED_STATE__ ? window.__PRELOADED_STATE__ : {};
9 |
10 | ReactDOM.hydrate((
11 |
12 |
13 |
14 | ), document.getElementById('app'));
15 |
--------------------------------------------------------------------------------
/react-src/components/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Redirect, Route, Switch} from 'react-router-dom'
3 |
4 | import Navigation from './nav'
5 | import Home from './home'
6 | import Dynamic from './dynamic'
7 | import Static from './static'
8 | import Footer from "./footer";
9 |
10 | class App extends React.Component {
11 |
12 | constructor(props) {
13 | super(props);
14 |
15 | this.state = {
16 | postcodes: this.props.store ? this.props.store.postcodes : [],
17 | postcodeQuery: this.props.store ? this.props.store.postcodeQuery : []
18 | };
19 | }
20 |
21 | render() {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
33 | }/>
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 | }
44 |
45 | export default App;
46 |
--------------------------------------------------------------------------------
/react-src/components/dynamic.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Search from "./search";
3 | import Postcode from "../api/postcode"
4 |
5 | class Dynamic extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | postcodeQuery: this.props.postcodeQuery,
12 | postcodes: this.props.postcodes
13 | };
14 |
15 | this.handlePostcodeQueryChange = this.handlePostcodeQueryChange.bind(this);
16 | }
17 |
18 | render() {
19 | return (
20 |
21 |
22 |
23 |
Some Dynamic Content
24 |
25 |
26 |
27 |
33 |
34 | {Dynamic.hasPostcodes(this.state.postcodes) && Dynamic.renderPostcodes(this.state.postcodes)}
35 |
36 |
37 |
38 | )
39 | }
40 |
41 | static hasPostcodes(postcodes) {
42 | return typeof postcodes !== "undefined" && postcodes !== null && postcodes.length > 0
43 | }
44 |
45 | static renderPostcodes(postcodes) {
46 | return (
47 |
48 |
49 |
50 |
51 | # |
52 | Postcode |
53 | Country |
54 | Region |
55 | Longitude |
56 | Latitude |
57 |
58 |
59 |
60 | {postcodes.map(function (postcodeDetails, index) {
61 | return
62 | {index + 1} |
63 | {postcodeDetails.postcode} |
64 | {postcodeDetails.country} |
65 | {postcodeDetails.region} |
66 | {postcodeDetails.longitude} |
67 | {postcodeDetails.latitude} |
68 |
69 | })}
70 |
71 |
72 |
73 | )
74 | }
75 |
76 | handlePostcodeQueryChange(postcodeQuery) {
77 |
78 | if (postcodeQuery !== this.state.postcodeQuery) {
79 | Postcode(postcodeQuery).then(
80 | res => {
81 | this.setState({
82 | postcodeQuery: postcodeQuery,
83 | postcodes: res.data,
84 | });
85 | }, err => {
86 | console.log(`Error fetching postcodes: [${err.message}]`)
87 | });
88 | }
89 | }
90 |
91 | }
92 |
93 | export default Dynamic
94 |
--------------------------------------------------------------------------------
/react-src/components/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class Footer extends React.Component {
4 |
5 | render() {
6 | return (
7 |
12 | )
13 | }
14 | }
15 |
16 | export default Footer
17 |
--------------------------------------------------------------------------------
/react-src/components/home.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {LinkContainer} from 'react-router-bootstrap'
3 | import {Button} from 'reactstrap';
4 |
5 | class Home extends React.Component {
6 |
7 | render() {
8 | return (
9 |
10 |
11 |
12 |
Sample App
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 | }
37 |
38 | export default Home
39 |
--------------------------------------------------------------------------------
/react-src/components/nav.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {LinkContainer} from 'react-router-bootstrap'
3 | import {Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink} from 'reactstrap';
4 |
5 | class Navigation extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 |
10 | this.toggleNavbar = this.toggleNavbar.bind(this);
11 | this.collapseNavbar = this.collapseNavbar.bind(this);
12 | this.state = {
13 | collapsed: true
14 | };
15 | }
16 |
17 | toggleNavbar() {
18 | this.setState({
19 | collapsed: !this.state.collapsed
20 | });
21 | }
22 |
23 | collapseNavbar() {
24 | if (!this.state.collapsed) {
25 | this.toggleNavbar();
26 | }
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
33 |
34 |
35 | Sample Nav
36 |
37 |
38 |
39 |
40 |
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | }
63 |
64 | export default Navigation
65 |
--------------------------------------------------------------------------------
/react-src/components/search.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class Search extends React.Component {
4 |
5 | constructor(props) {
6 | super(props);
7 | this.state = {
8 | value: this.props.initialValue
9 | };
10 |
11 | this.handleChange = this.handleChange.bind(this);
12 | this.handleSubmit = this.handleSubmit.bind(this);
13 | }
14 |
15 | handleChange(event) {
16 | this.setState({value: event.target.value});
17 | }
18 |
19 | handleSubmit(event) {
20 | event.preventDefault();
21 | this.props.onValueSubmitted(this.state.value);
22 | }
23 |
24 | render() {
25 | return (
26 |
27 |
36 |
37 | );
38 | }
39 | }
40 |
41 | export default Search;
42 |
--------------------------------------------------------------------------------
/react-src/components/static.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ssrImg from "../../public/images/java-server-side-render.jpg"
4 | import serverImg from "../../public/images/server-side-render.jpg"
5 | import javaImg from "../../public/images/ssr-static-image-server-side-rendering.jpg"
6 |
7 | class Static extends React.Component {
8 |
9 | render() {
10 | return (
11 |
12 |
13 |
14 |
Some Static Content
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | )
33 | }
34 | }
35 |
36 | export default Static
37 |
--------------------------------------------------------------------------------
/react-src/index.scss:
--------------------------------------------------------------------------------
1 | @import "~bootstrap/scss/bootstrap.scss";
2 | @import "~bootstrap/scss/_variables.scss";
3 | @import "~bootstrap/scss/_mixins.scss";
4 |
5 | .btn-primary {
6 | @include button-variant(#ddd, #959595, #bfbfbf);
7 | }
8 |
9 | button:focus {
10 | outline: 0 !important;
11 | }
12 |
13 | img {
14 | width: 100%;
15 | height: auto;
16 | }
17 |
18 | .brand-text {
19 | font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
20 | font-style: italic;
21 | }
22 |
23 | html {
24 | position: relative;
25 | min-height: 100%;
26 | }
27 |
28 | #body{
29 | margin-bottom: 100px;
30 | }
31 |
32 | .footer {
33 | position: absolute;
34 | bottom: 0;
35 | width: 100%;
36 | height: 60px;
37 | line-height: 60px;
38 | background-color: #e9ecef;
39 | }
40 |
--------------------------------------------------------------------------------
/react-src/server.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOMServer from 'react-dom/server'
3 | import serialize from 'serialize-javascript';
4 |
5 | import App from './components/app'
6 |
7 | global.render = (template, model) => {
8 |
9 | const location = model.get('currentPath');
10 | const routerContext = {};
11 |
12 | const initialState = JSON.parse(model.get('serverSideState'));
13 |
14 | const markup = ReactDOMServer.renderToString(
15 |
16 |
17 |
18 | );
19 |
20 | return template
21 | .replace('SERVER_RENDERED_HTML', markup)
22 | .replace('SERVER_RENDERED_STATE', serialize(initialState, {isJSON: true}));
23 | };
24 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/Application.java:
--------------------------------------------------------------------------------
1 | package com.djh;
2 |
3 | import org.springframework.boot.SpringApplication;
4 | import org.springframework.boot.autoconfigure.SpringBootApplication;
5 |
6 | /**
7 | * @author David Hancock
8 | */
9 | @SpringBootApplication
10 | public class Application {
11 |
12 | public static void main(String[] args) {
13 | SpringApplication.run(Application.class);
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/SSRConfiguration.java:
--------------------------------------------------------------------------------
1 | package com.djh;
2 |
3 | import org.springframework.context.annotation.Bean;
4 | import org.springframework.context.annotation.Configuration;
5 | import org.springframework.web.servlet.ViewResolver;
6 | import org.springframework.web.servlet.view.script.ScriptTemplateConfigurer;
7 | import org.springframework.web.servlet.view.script.ScriptTemplateViewResolver;
8 |
9 | /**
10 | * @author David Hancock
11 | */
12 | @Configuration
13 | public class SSRConfiguration {
14 |
15 | @Bean
16 | public ViewResolver reactViewResolver() {
17 | return new ScriptTemplateViewResolver("/public/", ".html");
18 | }
19 |
20 | @Bean
21 | public ScriptTemplateConfigurer templateConfigurer() {
22 |
23 | ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
24 | configurer.setEngineName("graal.js");
25 | configurer.setScripts(
26 | "public/server.js"
27 | );
28 | configurer.setRenderFunction("render");
29 | configurer.setSharedEngine(false);
30 | return configurer;
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/SSRController.java:
--------------------------------------------------------------------------------
1 | package com.djh;
2 |
3 | import com.djh.postcode.Postcode;
4 | import com.djh.postcode.PostcodeService;
5 | import com.fasterxml.jackson.core.JsonProcessingException;
6 | import com.fasterxml.jackson.databind.ObjectMapper;
7 | import org.slf4j.Logger;
8 | import org.slf4j.LoggerFactory;
9 | import org.springframework.stereotype.Controller;
10 | import org.springframework.ui.Model;
11 | import org.springframework.web.bind.annotation.GetMapping;
12 |
13 | import javax.servlet.http.HttpServletRequest;
14 | import java.util.HashMap;
15 | import java.util.List;
16 | import java.util.Map;
17 |
18 | /**
19 | * @author David Hancock
20 | */
21 | @Controller
22 | public class SSRController {
23 |
24 | private static final Logger log = LoggerFactory.getLogger(SSRController.class);
25 |
26 | private final PostcodeService postcodeService;
27 | private final ObjectMapper objectMapper;
28 |
29 | public SSRController(PostcodeService postcodeService, ObjectMapper objectMapper) {
30 | this.postcodeService = postcodeService;
31 | this.objectMapper = objectMapper;
32 | }
33 |
34 | @GetMapping("/{path:(?!.*.js|.*.css|.*.jpg).*$}")
35 | public String render(Model model, HttpServletRequest request) {
36 |
37 | addCurrentPath(model, request);
38 | addServerSideContent(model);
39 | return "index";
40 | }
41 |
42 | private void addCurrentPath(Model model, HttpServletRequest request) {
43 |
44 | String path = request.getServletPath();
45 | if (request.getServletPath().equals("/index.html")) {
46 | path = "/";
47 | }
48 |
49 | if (request.getQueryString() != null) {
50 | path = String.format("%s?%s", path, request.getQueryString());
51 | }
52 | model.addAttribute("currentPath", path);
53 | }
54 |
55 | private void addServerSideContent(Model model) {
56 |
57 | String initialPostcodeQuery = "ST3";
58 | List postcodes = postcodeService.retrievePostcodesFor(initialPostcodeQuery);
59 |
60 | Map serverSideState = new HashMap<>();
61 | serverSideState.put("postcodeQuery", initialPostcodeQuery);
62 | serverSideState.put("postcodes", postcodes);
63 | try {
64 | model.addAttribute("serverSideState", objectMapper.writeValueAsString(serverSideState));
65 | } catch (JsonProcessingException e) {
66 | log.error("Failed to serialize image posts", e);
67 | }
68 | }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/postcode/Postcode.java:
--------------------------------------------------------------------------------
1 | package com.djh.postcode;
2 |
3 | /**
4 | * @author David Hancock
5 | */
6 | public class Postcode {
7 |
8 | private String postcode;
9 | private String country;
10 | private String region;
11 | private String longitude;
12 | private String latitude;
13 |
14 | public String getPostcode() {
15 | return postcode;
16 | }
17 |
18 | public String getCountry() {
19 | return country;
20 | }
21 |
22 | public String getRegion() {
23 | return region;
24 | }
25 |
26 | public String getLongitude() {
27 | return longitude;
28 | }
29 |
30 | public String getLatitude() {
31 | return latitude;
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/postcode/PostcodeAPIClient.java:
--------------------------------------------------------------------------------
1 | package com.djh.postcode;
2 |
3 | import org.springframework.web.client.RestTemplate;
4 |
5 | import java.util.ArrayList;
6 | import java.util.List;
7 |
8 | /**
9 | * @author David Hancock
10 | */
11 | public class PostcodeAPIClient {
12 |
13 | private static final String API_ENDPOINT = "https://api.postcodes.io";
14 | private static final String QUERY_STRING = "/postcodes/?q=";
15 |
16 | private final RestTemplate restTemplate;
17 |
18 | public PostcodeAPIClient(RestTemplate restTemplate) {
19 | this.restTemplate = restTemplate;
20 | }
21 |
22 | public List getPostcodesFor(String query) {
23 |
24 | PostcodesResponse postcodesResponse = restTemplate.getForObject(
25 | API_ENDPOINT + QUERY_STRING + query,
26 | PostcodesResponse.class);
27 |
28 | List postcodes = postcodesResponse.getResult();
29 | if (postcodes == null) {
30 | return new ArrayList<>();
31 | } else {
32 | return postcodes;
33 | }
34 | }
35 |
36 | private static class PostcodesResponse {
37 |
38 | private List result;
39 |
40 | public PostcodesResponse() {
41 | }
42 |
43 | public List getResult() {
44 | return result;
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/postcode/PostcodeConfiguration.java:
--------------------------------------------------------------------------------
1 | package com.djh.postcode;
2 |
3 | import org.springframework.cache.annotation.EnableCaching;
4 | import org.springframework.context.annotation.Bean;
5 | import org.springframework.context.annotation.Configuration;
6 | import org.springframework.web.client.RestTemplate;
7 |
8 | /**
9 | * @author David Hancock
10 | */
11 | @EnableCaching
12 | @Configuration
13 | public class PostcodeConfiguration {
14 |
15 | @Bean
16 | public PostcodeService postcodeService(PostcodeAPIClient postcodeAPIClient) {
17 | return new PostcodeService(postcodeAPIClient);
18 | }
19 |
20 | @Bean
21 | public PostcodeAPIClient postcodeAPIClient(RestTemplate restTemplate) {
22 | return new PostcodeAPIClient(restTemplate);
23 | }
24 |
25 | @Bean
26 | public RestTemplate restTemplate() {
27 | return new RestTemplate();
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/postcode/PostcodeController.java:
--------------------------------------------------------------------------------
1 | package com.djh.postcode;
2 |
3 | import org.springframework.web.bind.annotation.GetMapping;
4 | import org.springframework.web.bind.annotation.PathVariable;
5 | import org.springframework.web.bind.annotation.RestController;
6 |
7 | import java.util.List;
8 |
9 | /**
10 | * @author David Hancock
11 | */
12 | @RestController
13 | public class PostcodeController {
14 |
15 | private final PostcodeService postcodeService;
16 |
17 | public PostcodeController(PostcodeService postcodeService) {
18 | this.postcodeService = postcodeService;
19 | }
20 |
21 | @GetMapping("/postcode/{postcode}")
22 | public List getPostcodes(@PathVariable String postcode) {
23 | return postcodeService.retrievePostcodesFor(postcode);
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/java/com/djh/postcode/PostcodeService.java:
--------------------------------------------------------------------------------
1 | package com.djh.postcode;
2 |
3 | import org.springframework.cache.annotation.Cacheable;
4 |
5 | import java.util.List;
6 |
7 | /**
8 | * @author David Hancock
9 | */
10 | public class PostcodeService {
11 |
12 | private final PostcodeAPIClient postcodeAPIClient;
13 |
14 | public PostcodeService(PostcodeAPIClient postcodeAPIClient) {
15 | this.postcodeAPIClient = postcodeAPIClient;
16 | }
17 |
18 | @Cacheable("postcodes")
19 | public List retrievePostcodesFor(String query) {
20 | return postcodeAPIClient.getPostcodesFor(query);
21 | }
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/webpack.config.dev.babel.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const webpack = require('webpack');
5 |
6 | module.exports = {
7 | mode: 'development',
8 | entry: './react-src/client.js',
9 | output: {
10 | filename: '[name].js',
11 | },
12 |
13 | devServer: {
14 | contentBase: path.join(__dirname, 'public'),
15 | hot: true
16 | },
17 |
18 | devtool: 'inline-source-map',
19 |
20 | plugins: [
21 | new HtmlWebpackPlugin({template: './public/index.html'}),
22 | new webpack.HotModuleReplacementPlugin()
23 | ],
24 |
25 | module: {
26 | rules: [
27 | {
28 | test: /\.scss$/,
29 | use: [
30 | "style-loader",
31 | "css-loader",
32 | "sass-loader"
33 | ]
34 | },
35 | {
36 | test: /\.jpg$/,
37 | use: 'file-loader'
38 | },
39 | {
40 | test: /\.js$/,
41 | exclude: /node_modules/,
42 | use: 'babel-loader',
43 | },
44 | ],
45 | },
46 | };
47 |
--------------------------------------------------------------------------------
/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const CleanWebpackPlugin = require('clean-webpack-plugin');
5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
6 |
7 | const SERVER_OUTPUT_PATH = 'src/main/resources/public';
8 |
9 | module.exports = [
10 | // Client (frontend) bundle
11 | {
12 | mode: 'production',
13 | entry: './react-src/client.js',
14 |
15 | output: {
16 | filename: 'client.[chunkhash:8].js',
17 | path: path.resolve(__dirname, SERVER_OUTPUT_PATH),
18 | },
19 |
20 | devtool: 'source-map',
21 |
22 | plugins: [
23 | new CleanWebpackPlugin([SERVER_OUTPUT_PATH]),
24 | new HtmlWebpackPlugin({template: './public/index.html'}),
25 | new MiniCssExtractPlugin()
26 | ],
27 |
28 | module: {
29 | rules: [
30 | {
31 | test: /\.scss$/,
32 | use: [
33 | MiniCssExtractPlugin.loader,
34 | "css-loader",
35 | "sass-loader"
36 | ]
37 | },
38 | {
39 | test: /\.jpg$/,
40 | use: 'file-loader'
41 | },
42 | {
43 | test: /\.js$/,
44 | exclude: /node_modules/,
45 | use: 'babel-loader',
46 | },
47 | ],
48 | },
49 | },
50 | // Server side (SSR) bundle
51 | {
52 | mode: 'production',
53 | entry: './react-src/server.js',
54 |
55 | output: {
56 | filename: 'server.js',
57 | path: path.resolve(__dirname, SERVER_OUTPUT_PATH),
58 | },
59 |
60 | plugins: [
61 | new CleanWebpackPlugin([SERVER_OUTPUT_PATH]),
62 | ],
63 |
64 | module: {
65 | rules: [
66 | {
67 | test: /\.jpg$/,
68 | use: 'file-loader'
69 | },
70 | {
71 | test: /\.js$/,
72 | exclude: /node_modules/,
73 | use: 'babel-loader',
74 | },
75 | ],
76 | },
77 | },
78 | ];
79 |
--------------------------------------------------------------------------------