├── .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 |
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 |
28 |
29 | 31 |
32 |
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 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {postcodes.map(function (postcodeDetails, index) { 61 | return 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | })} 70 | 71 |
#PostcodeCountryRegionLongitudeLatitude
{index + 1}{postcodeDetails.postcode}{postcodeDetails.country}{postcodeDetails.region}{postcodeDetails.longitude}{postcodeDetails.latitude}
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 |
28 | 32 | 33 | 34 | 35 |
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 | ssr server 22 |
23 |
24 | server side 25 |
26 |
27 | java server side rendering 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 | --------------------------------------------------------------------------------