├── .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 | [](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 |
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 | class Static extends React.Component {
4 |
5 | render() {
6 | return (
7 |
8 |
9 |
10 |
Some Static Content
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
--------------------------------------------------------------------------------