├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── package.json ├── public ├── duktape-polyfill.js ├── images │ ├── golang-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 ├── scripts ├── build.js └── start.js ├── src ├── main.go ├── postcode │ ├── postcode-controller.go │ ├── postcode-controller_test.go │ ├── postcode-service.go │ └── postcode-service_test.go └── render │ └── engine.go ├── webpack.config.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/react-ssr-sample-golang 5 | docker: 6 | - image: circleci/node:7 7 | 8 | steps: 9 | - checkout 10 | 11 | - restore_cache: 12 | key: frontend-deps-{{ checksum "package.json" }} 13 | 14 | - run: 15 | name: Install Frontend 16 | command: yarn install 17 | 18 | - run: 19 | name: Build Client Side 20 | command: yarn build 21 | 22 | - save_cache: 23 | key: frontend-deps-{{ checksum "package.json" }} 24 | paths: 25 | - ./node_modules 26 | 27 | - persist_to_workspace: 28 | root: . 29 | paths: 30 | - 'Dockerfile' 31 | - 'react-build' 32 | - 'public' 33 | - 'src' 34 | - 'Gopkg.lock' 35 | - 'Gopkg.toml' 36 | 37 | deploy: 38 | working_directory: ~/react-ssr-sample-golang 39 | machine: true 40 | 41 | steps: 42 | - attach_workspace: 43 | at: ~/react-ssr-sample-golang 44 | 45 | - run: 46 | name: Docker Build 47 | command: docker build -t daves125125/react-ssr-sample-golang . 48 | 49 | - run: 50 | name: Docker Login 51 | command: docker login -u $DOCKER_USER -p $DOCKER_PASSWORD 52 | 53 | - run: 54 | name: Docker Push 55 | command: docker push daves125125/react-ssr-sample-golang 56 | 57 | workflows: 58 | version: 2 59 | build_and_deploy: 60 | jobs: 61 | - build 62 | - deploy: 63 | requires: 64 | - build 65 | filters: 66 | branches: 67 | only: master 68 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !src 3 | !Gopkg.toml 4 | !Gopkg.lock 5 | 6 | !webpack.config.js 7 | !package.json 8 | !yarn.lock 9 | 10 | !public 11 | !react-src 12 | 13 | !react-build 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # dependencies 3 | /node_modules 4 | 5 | # production 6 | /build 7 | 8 | # misc 9 | .DS_Store 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | .idea 20 | *.iml 21 | 22 | react-build 23 | vendor 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.9-stretch AS build-env 3 | RUN curl -fsSL -o /usr/local/bin/dep https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 && chmod +x /usr/local/bin/dep 4 | RUN mkdir -p /go/src/github.com/daves125125/react-ssr-sample-golang 5 | WORKDIR /go/src/github.com/daves125125/react-ssr-sample-golang 6 | COPY . ./ 7 | RUN dep ensure 8 | RUN cd src && env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o goapp 9 | 10 | # Package stage 11 | FROM debian:9-slim 12 | RUN apt-get update && apt-get install -y ca-certificates 13 | COPY --from=build-env /go/src/github.com/daves125125/react-ssr-sample-golang/src/goapp /app/goapp 14 | COPY ./react-build/ /app/react-build/ 15 | WORKDIR /app 16 | EXPOSE 8080 17 | ENTRYPOINT [ "/app/goapp" ] 18 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "v3" 6 | name = "gopkg.in/olebedev/go-duktape.v3" 7 | packages = ["."] 8 | revision = "3c4db4ad4f2db84859454dc805d6eb7d8051a8ce" 9 | 10 | [solve-meta] 11 | analyzer-name = "dep" 12 | analyzer-version = 1 13 | inputs-digest = "8dd97dcb45a5ec4b0e468fa08b69cde5d49f3f94ab9453e519be497030692a8f" 14 | solver-name = "gps-cdcl" 15 | solver-version = 1 16 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "github.com/robertkrimen/otto" 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-ssr-sample-golang 2 | 3 | [![CircleCI](https://circleci.com/gh/daves125125/react-ssr-sample-golang.svg?style=svg&circle-token=5429e86f218c76bc2fe839e08b94cd42fcde5e0a)](https://circleci.com/gh/daves125125/react-ssr-sample-golang) 4 | 5 | This project demonstrates rendering a react app on the server side using Golang. 6 | 7 | 8 | ## TL;DR 9 | 10 | The client side code consists of a small React app that uses some popular libraries such as react-router, bootstrap etc. It 11 | 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. 12 | 13 | The server side code consists of a simple Go application that renders JS with the help of [Duktape](http://duktape.org/index.html) and [Go Duktape bindings](https://github.com/olebedev/go-duktape). The server side app fetches some basic postcode data available from a third party, open API - https://api.postcodes.io. 14 | 15 | 16 | ## Run 17 | 18 | This sample has been packaged as a docker container and can be ran by executing: 19 | 20 | ``` 21 | docker run -p8080:8080 daves125125/react-ssr-sample-golang 22 | ``` 23 | 24 | Navigate to `localhost:8080/` to see the sample running. 25 | 26 | 27 | ## Build / Run from source 28 | ``` 29 | yarn install && yarn build && go run src/main.go 30 | ``` 31 | 32 | Or, via Docker: 33 | 34 | ``` 35 | yarn install && yarn build 36 | docker build -t test . 37 | docker run -p8080:8080 test 38 | ``` 39 | 40 | 41 | ## How this works / Areas of interest 42 | 43 | The JS code is split into two main bundles, the client.js and server.js. These are built as independent source sets 44 | by Webpack. Both the server.js and client.js depend upon the the main React App itself with the only difference being 45 | that the client side component includes client side specific code such as browser routing, and the server side code includes 46 | server side routing and injection of initial state. 47 | 48 | The Server side uses Duktape to render only the server.js bundle which gets packaged as part of the build process. 49 | 50 | Regarding SSR, the main files of interest are: 51 | 52 | - react-src/client.js 53 | - react-src/server.js 54 | - src/main.go (contains the entrypoint and routing logic) 55 | - src/render/engine.go (contains the rendering / binding logic to Duktape) 56 | 57 | 58 | ## Performance 59 | 60 | 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) 61 | 62 | | | At Rest / Startup | Under Load | 63 | | ------------------- |:------------------:| -----------:| 64 | | Render Speed (ms) | ~60 | ~60 | 65 | | Throughput (msgs/s) | ~16 | ~16 | 66 | | Memory Usage (Mb) | ~9 | ~20 | 67 | 68 | 69 | ## Known TODOs 70 | 71 | - Refactoring needed on the Go sample code itself (few questionable areas and not so idiomatic) 72 | - Render speed is a little slower than expected, presumably because this is fresh evaluation of the script on each invocation (i.e no caching or up front compiling of the scripts) 73 | - Caching could be easily implemented, both on the templates and the server side state within the service. 74 | - Properly strip down webpack config and ejected create-react-app to barebones needed 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr-sample-java", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "babel-polyfill": "^6.26.0", 7 | "bootstrap": "^4.1.3", 8 | "jquery": "^3.2.1", 9 | "object-assign": "^4.1.1", 10 | "popper.js": "^1.12.9", 11 | "react": "^16.2.0", 12 | "react-dev-utils": "^4.2.1", 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": "^6.3.1", 18 | "serialize-javascript": "^1.4.0" 19 | }, 20 | "scripts": { 21 | "start": "webpack-dev-server", 22 | "build": "webpack -p" 23 | }, 24 | "babel": { 25 | "presets": [ 26 | "react", 27 | "env" 28 | ] 29 | }, 30 | "devDependencies": { 31 | "autoprefixer": "^7.2.6", 32 | "babel-core": "^6.26.3", 33 | "babel-loader": "^7.1.5", 34 | "babel-preset-env": "^1.7.0", 35 | "babel-preset-react": "^6.24.1", 36 | "babel-runtime": "^6.26.0", 37 | "copy-webpack-plugin": "^4.5.2", 38 | "css-loader": "0.28.7", 39 | "extract-text-webpack-plugin": "^2.0.0", 40 | "file-loader": "^1.1.11", 41 | "fs-extra": "^3.0.1", 42 | "html-webpack-plugin": "2.29.0", 43 | "node-sass": "^4.7.2", 44 | "sass-loader": "^6.0.6", 45 | "style-loader": "^0.19.1", 46 | "webpack": "^2.0.0", 47 | "webpack-dev-server": "^2.11.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/duktape-polyfill.js: -------------------------------------------------------------------------------- 1 | var window = this; 2 | var console = { 3 | error: print, 4 | debug: print, 5 | warn: print, 6 | log: print 7 | }; 8 | 9 | window.setTimeout = function () { 10 | }; 11 | 12 | window.clearTimeout = function () { 13 | }; 14 | -------------------------------------------------------------------------------- /public/images/golang-server-side-render.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davehancock/react-ssr-sample-golang/0783bf1155d1af221906ca8c826020e3fcbdba95/public/images/golang-server-side-render.jpg -------------------------------------------------------------------------------- /public/images/server-side-render.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davehancock/react-ssr-sample-golang/0783bf1155d1af221906ca8c826020e3fcbdba95/public/images/server-side-render.jpg -------------------------------------------------------------------------------- /public/images/ssr-static-image-server-side-rendering.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davehancock/react-ssr-sample-golang/0783bf1155d1af221906ca8c826020e3fcbdba95/public/images/ssr-static-image-server-side-rendering.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React SSR Sample Golang 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 | class Static extends React.Component { 4 | 5 | render() { 6 | return ( 7 |
8 |
9 |
10 |

Some Static Content

11 |
12 |
13 | 14 |
15 |
16 |
17 | ssr server 18 |
19 |
20 | server side 21 |
22 |
23 | golang server side rendering 24 |
25 |
26 |
27 |
28 | ) 29 | } 30 | } 31 | 32 | export default Static 33 | -------------------------------------------------------------------------------- /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 { StaticRouter } from 'react-router' 4 | import serialize from 'serialize-javascript'; 5 | 6 | import App from './components/app' 7 | 8 | 9 | window.render = (template, currentPath, serverSideState) => { 10 | 11 | const location = currentPath; 12 | const routerContext = {}; 13 | 14 | const initialState = JSON.parse(serverSideState); 15 | 16 | const markup = ReactDOMServer.renderToString( 17 | 18 | 19 | 20 | ); 21 | 22 | return template 23 | .replace('SERVER_RENDERED_HTML', markup) 24 | .replace('SERVER_RENDERED_STATE', serialize(initialState, { isJSON: true })); 25 | }; 26 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'production'; 4 | process.env.NODE_ENV = 'production'; 5 | 6 | process.on('unhandledRejection', err => { 7 | throw err; 8 | }); 9 | 10 | require('../config/env'); 11 | 12 | const path = require('path'); 13 | const chalk = require('chalk'); 14 | const fs = require('fs-extra'); 15 | const webpack = require('webpack'); 16 | const config = require('../config/webpack.config.prod'); 17 | const paths = require('../config/paths'); 18 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 19 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); 20 | const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); 21 | const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); 22 | 23 | const measureFileSizesBeforeBuild = 24 | FileSizeReporter.measureFileSizesBeforeBuild; 25 | const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; 26 | const useYarn = fs.existsSync(paths.yarnLockFile); 27 | 28 | const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; 29 | const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; 30 | 31 | if (!checkRequiredFiles([paths.appHtml, paths.appClientJs, paths.appServerJs])) { 32 | process.exit(1); 33 | } 34 | 35 | measureFileSizesBeforeBuild(paths.appBuild) 36 | .then(previousFileSizes => { 37 | fs.emptyDirSync(paths.appBuild); 38 | copyPublicFolder(); 39 | return build(previousFileSizes); 40 | }) 41 | .then( 42 | ({stats, previousFileSizes, warnings}) => { 43 | if (warnings.length) { 44 | console.log(chalk.yellow('Compiled with warnings.\n')); 45 | console.log(warnings.join('\n\n')); 46 | console.log( 47 | '\nSearch for the ' + 48 | chalk.underline(chalk.yellow('keywords')) + 49 | ' to learn more about each warning.' 50 | ); 51 | console.log( 52 | 'To ignore, add ' + 53 | chalk.cyan('// eslint-disable-next-line') + 54 | ' to the line before.\n' 55 | ); 56 | } else { 57 | console.log(chalk.green('Compiled successfully.\n')); 58 | } 59 | 60 | console.log('File sizes after gzip:\n'); 61 | 62 | for (let i in stats.stats) { 63 | printFileSizesAfterBuild( 64 | stats.stats[i], 65 | previousFileSizes, 66 | paths.appBuild, 67 | WARN_AFTER_BUNDLE_GZIP_SIZE, 68 | WARN_AFTER_CHUNK_GZIP_SIZE 69 | ); 70 | console.log(); 71 | 72 | const appPackage = require(paths.appPackageJson); 73 | const publicUrl = paths.publicUrl; 74 | const publicPath = config[i].output.publicPath; 75 | const buildFolder = path.relative(process.cwd(), paths.appBuild); 76 | printHostingInstructions( 77 | appPackage, 78 | publicUrl, 79 | publicPath, 80 | buildFolder, 81 | useYarn 82 | ); 83 | } 84 | }, 85 | err => { 86 | console.log(chalk.red('Failed to compile.\n')); 87 | console.log((err.message || err) + '\n'); 88 | process.exit(1); 89 | } 90 | ); 91 | 92 | function build(previousFileSizes) { 93 | console.log('Creating an optimized production build...'); 94 | 95 | let compiler = webpack(config); 96 | return new Promise((resolve, reject) => { 97 | compiler.run((err, stats) => { 98 | if (err) { 99 | return reject(err); 100 | } 101 | const messages = formatWebpackMessages(stats.toJson({}, true)); 102 | if (messages.errors.length) { 103 | return reject(new Error(messages.errors.join('\n\n'))); 104 | } 105 | if ( 106 | process.env.CI && 107 | (typeof process.env.CI !== 'string' || 108 | process.env.CI.toLowerCase() !== 'false') && 109 | messages.warnings.length 110 | ) { 111 | console.log( 112 | chalk.yellow( 113 | '\nTreating warnings as errors because process.env.CI = true.\n' + 114 | 'Most CI servers set it automatically.\n' 115 | ) 116 | ); 117 | return reject(new Error(messages.warnings.join('\n\n'))); 118 | } 119 | return resolve({ 120 | stats, 121 | previousFileSizes, 122 | warnings: messages.warnings, 123 | }); 124 | }); 125 | }); 126 | } 127 | 128 | function copyPublicFolder() { 129 | fs.copySync(paths.appPublic, paths.appBuild, { 130 | dereference: true, 131 | filter: file => file !== paths.appHtml, 132 | }); 133 | } 134 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'development'; 4 | process.env.NODE_ENV = 'development'; 5 | 6 | process.on('unhandledRejection', err => { 7 | throw err; 8 | }); 9 | 10 | require('../config/env'); 11 | 12 | const fs = require('fs'); 13 | const chalk = require('chalk'); 14 | const webpack = require('webpack'); 15 | const WebpackDevServer = require('webpack-dev-server'); 16 | const clearConsole = require('react-dev-utils/clearConsole'); 17 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); 18 | const { 19 | choosePort, 20 | createCompiler, 21 | prepareProxy, 22 | prepareUrls, 23 | } = require('react-dev-utils/WebpackDevServerUtils'); 24 | const openBrowser = require('react-dev-utils/openBrowser'); 25 | const paths = require('../config/paths'); 26 | const config = require('../config/webpack.config.dev'); 27 | const createDevServerConfig = require('../config/webpackDevServer.config'); 28 | 29 | const useYarn = fs.existsSync(paths.yarnLockFile); 30 | const isInteractive = process.stdout.isTTY; 31 | 32 | if (!checkRequiredFiles([paths.appHtml, paths.appClientJs, paths.appServerJs])) { 33 | process.exit(1); 34 | } 35 | 36 | const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; 37 | const HOST = process.env.HOST || '0.0.0.0'; 38 | 39 | choosePort(HOST, DEFAULT_PORT) 40 | .then(port => { 41 | if (port == null) { 42 | return; 43 | } 44 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; 45 | const appName = require(paths.appPackageJson).name; 46 | const urls = prepareUrls(protocol, HOST, port); 47 | const compiler = createCompiler(webpack, config, appName, urls, useYarn); 48 | const proxySetting = require(paths.appPackageJson).proxy; 49 | const proxyConfig = prepareProxy(proxySetting, paths.appPublic); 50 | const serverConfig = createDevServerConfig( 51 | proxyConfig, 52 | urls.lanUrlForConfig 53 | ); 54 | const devServer = new WebpackDevServer(compiler, serverConfig); 55 | devServer.listen(port, HOST, err => { 56 | if (err) { 57 | return console.log(err); 58 | } 59 | if (isInteractive) { 60 | clearConsole(); 61 | } 62 | console.log(chalk.cyan('Starting the development server...\n')); 63 | openBrowser(urls.localUrlForBrowser); 64 | }); 65 | 66 | ['SIGINT', 'SIGTERM'].forEach(function (sig) { 67 | process.on(sig, function () { 68 | devServer.close(); 69 | process.exit(); 70 | }); 71 | }); 72 | }) 73 | .catch(err => { 74 | if (err && err.message) { 75 | console.log(err.message); 76 | } 77 | process.exit(1); 78 | }); 79 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/daves125125/react-ssr-sample-golang/src/postcode" 11 | "github.com/daves125125/react-ssr-sample-golang/src/render" 12 | ) 13 | 14 | var config *Config 15 | var engine *render.Engine 16 | 17 | type Config struct { 18 | polyfillLocation string 19 | scriptLocation string 20 | templateLocation string 21 | staticDir string 22 | port string 23 | } 24 | 25 | // TODO Can abstract this and have injection via env vars or CLI through Cobra etc 26 | func init() { 27 | c := new(Config) 28 | 29 | pwd, _ := os.Getwd() 30 | c.polyfillLocation = pwd + "/react-build/duktape-polyfill.js" 31 | c.scriptLocation = pwd + "/react-build/static/js/server.js" 32 | c.templateLocation = pwd + "/react-build/index.html" 33 | c.staticDir = pwd + "/react-build" 34 | c.port = "8080" 35 | config = c 36 | 37 | engine = render.NewEngine(c.polyfillLocation, c.scriptLocation, c.templateLocation) 38 | } 39 | 40 | func main() { 41 | 42 | log.Println("Starting SSR Sample...") 43 | 44 | // These routes are extra specific as they clash with the dynamic "/static" route. 45 | // Could instead use regex with gorilla mux, but would complicate the sample with marginal benefit. 46 | fs := http.FileServer(http.Dir(config.staticDir)) 47 | http.Handle("/static/js/", fs) 48 | http.Handle("/static/css/", fs) 49 | http.Handle("/images/", fs) 50 | 51 | http.HandleFunc("/postcode/", postcode.HandlePostcodeQuery) 52 | http.HandleFunc("/", handleDynamicRoute) 53 | 54 | log.Println("Listening on port:", config.port) 55 | http.ListenAndServe(":"+config.port, nil) 56 | } 57 | 58 | func handleDynamicRoute(w http.ResponseWriter, r *http.Request) { 59 | renderedTemplate := engine.Render(r.URL.Path, resolveServerSideState()) 60 | w.Write([]byte(renderedTemplate)) 61 | } 62 | 63 | type serverSideState struct { 64 | PostcodeQuery string `json:"postcodeQuery"` 65 | Postcodes []postcode.Postcode `json:"postcodes"` 66 | } 67 | 68 | func resolveServerSideState() string { 69 | 70 | initialPostcode := "ST3" 71 | 72 | serverSideState := serverSideState{} 73 | serverSideState.PostcodeQuery = initialPostcode 74 | serverSideState.Postcodes = postcode.FetchPostcodes(initialPostcode) 75 | 76 | serverSideStateJSON, err := json.Marshal(serverSideState) 77 | if err != nil { 78 | // TODO Handle this properly 79 | fmt.Println(err) 80 | } 81 | 82 | return string(serverSideStateJSON) 83 | } 84 | -------------------------------------------------------------------------------- /src/postcode/postcode-controller.go: -------------------------------------------------------------------------------- 1 | package postcode 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | // HandlePostcodeQuery returns JSON representing a series of postcodes for a given request 11 | func HandlePostcodeQuery(w http.ResponseWriter, r *http.Request) { 12 | 13 | extractedPostcode := extractPostcodeFromPath(r.URL.Path) 14 | postcodes := FetchPostcodes(extractedPostcode) 15 | 16 | postcodeJSON, err := json.Marshal(postcodes) 17 | if err != nil { 18 | // TODO Handle this properly 19 | fmt.Println(err) 20 | } 21 | 22 | w.Write([]byte(postcodeJSON)) 23 | } 24 | 25 | func extractPostcodeFromPath(path string) string { 26 | return strings.SplitAfter(path, "/postcode/")[1] 27 | } 28 | -------------------------------------------------------------------------------- /src/postcode/postcode-controller_test.go: -------------------------------------------------------------------------------- 1 | package postcode 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestManipulateString(t *testing.T) { 9 | 10 | res := extractPostcodeFromPath("/postcode/ST3") 11 | 12 | if !(res == "ST3") { 13 | fmt.Println(res) 14 | t.Fail() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/postcode/postcode-service.go: -------------------------------------------------------------------------------- 1 | package postcode 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "encoding/json" 9 | ) 10 | 11 | const apiEndpoint = "https://api.postcodes.io" 12 | const queryString = "/postcodes/?q=" 13 | 14 | type PostcodeResponse struct { 15 | Status int `json:"status"` 16 | Result []Postcode `json:"result"` 17 | } 18 | 19 | type Postcode struct { 20 | Postcode string `json:"postcode"` 21 | Country string `json:"country"` 22 | Region string `json:"region"` 23 | Longitude float32 `json:"longitude"` 24 | Latitude float32 `json:"latitude"` 25 | } 26 | 27 | func FetchPostcodes(postcode string) []Postcode { 28 | 29 | resp, err := http.Get(apiEndpoint + queryString + postcode) 30 | if err != nil { 31 | // TODO handle error 32 | fmt.Println(err) 33 | } 34 | defer resp.Body.Close() 35 | body, err := ioutil.ReadAll(resp.Body) 36 | 37 | postcodeRes := PostcodeResponse{0, make([]Postcode, 0)} 38 | json.Unmarshal(body, &postcodeRes) 39 | 40 | postcodeResults := postcodeRes.Result 41 | if postcodeResults == nil { 42 | postcodeResults = make([]Postcode, 0) 43 | } 44 | 45 | return postcodeResults 46 | } 47 | -------------------------------------------------------------------------------- /src/postcode/postcode-service_test.go: -------------------------------------------------------------------------------- 1 | package postcode 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestPostcodeService(t *testing.T) { 9 | 10 | res := FetchPostcodes("ST1") 11 | postcode1 := res[0] 12 | 13 | if !(postcode1.Postcode == "ST1 1AP") { 14 | fmt.Println(res) 15 | t.Fail() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/render/engine.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "gopkg.in/olebedev/go-duktape.v3" 8 | ) 9 | 10 | type Engine struct { 11 | polyfillContents string 12 | scriptContents string 13 | templateContents string 14 | } 15 | 16 | func NewEngine(polyfillLocation, scriptLocation, templateLocation string) *Engine { 17 | 18 | e := new(Engine) 19 | e.polyfillContents = loadFileContents(polyfillLocation) 20 | e.scriptContents = loadFileContents(scriptLocation) 21 | e.templateContents = loadFileContents(templateLocation) 22 | return e 23 | } 24 | 25 | func loadFileContents(filePath string) string { 26 | 27 | scriptContents, err := ioutil.ReadFile(filePath) 28 | if err != nil { 29 | // TODO Handle this properly 30 | fmt.Println(err) 31 | panic(err) 32 | } 33 | return string(scriptContents) 34 | } 35 | 36 | // We create and teardown a new duktape context per invocation, bit wasteful but likely not thread safe otherwise 37 | func (e *Engine) Render(currentPath string, serverSideState string) string { 38 | 39 | ctx := duktape.New() 40 | 41 | if err := ctx.PevalString(e.polyfillContents); err != nil { 42 | fmt.Println("Error evaluating polyfill:", err, "Line number:", err.(*duktape.Error).LineNumber) 43 | panic(err.(*duktape.Error).Message) 44 | } 45 | 46 | if err := ctx.PevalString(e.scriptContents); err != nil { 47 | fmt.Println("Error evaluating script:", err, "Line number:", err.(*duktape.Error).LineNumber) 48 | panic(err.(*duktape.Error).Message) 49 | } 50 | 51 | // TODO All of the below could panic 52 | ctx.GetGlobalString("render") 53 | ctx.PushString(e.templateContents) 54 | ctx.PushString(currentPath) 55 | ctx.PushString(serverSideState) 56 | ctx.Call(3) 57 | 58 | result := ctx.GetString(-1) 59 | 60 | ctx.DestroyHeap() 61 | 62 | return result 63 | } 64 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | 6 | module.exports = 7 | [ 8 | // Client (frontend) bundle 9 | { 10 | entry: './react-src/client.js', 11 | output: { 12 | path: path.join(__dirname, 'react-build'), 13 | filename: 'static/js/client.[chunkhash:8].js', 14 | }, 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'babel-loader', 22 | options: { 23 | presets: ['env', 'react'], 24 | babelrc: false, 25 | }, 26 | }, 27 | }, 28 | { 29 | test: /\.scss$/, 30 | use: ExtractTextPlugin.extract({ 31 | fallback: 'style-loader', 32 | use: [ 33 | { 34 | loader: 'css-loader', 35 | options: { 36 | minimize: true, 37 | sourceMap: true, 38 | importLoaders: 2, 39 | localIdentName: '[name]__[local]___[hash:base64:5]' 40 | } 41 | }, 42 | 'sass-loader' 43 | ] 44 | }) 45 | }, 46 | ], 47 | }, 48 | plugins: [ 49 | new ExtractTextPlugin(`static/css/client.[contenthash:8].css`), 50 | new HtmlWebPackPlugin({ 51 | template: "public/index.html", 52 | }) 53 | ], 54 | }, 55 | // Server side (SSR) bundle 56 | { 57 | entry: ['babel-polyfill', './react-src/server.js'], 58 | output: { 59 | path: path.join(__dirname, 'react-build'), 60 | filename: 'static/js/server.js' 61 | }, 62 | module: { 63 | loaders: [ 64 | { 65 | test: /\.js$/, 66 | exclude: /node_modules/, 67 | use: { 68 | loader: 'babel-loader', 69 | options: { 70 | presets: ['env', 'react'], 71 | babelrc: false, 72 | }, 73 | }, 74 | }, 75 | ], 76 | }, 77 | plugins: [ 78 | new CopyWebpackPlugin([ 79 | { from: 'public', ignore: ['*.html'] } 80 | ]) 81 | ] 82 | } 83 | ]; 84 | --------------------------------------------------------------------------------