├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── circle.yml ├── config └── dev-environment.js ├── index.js ├── lambda ├── index.js └── main.js ├── package.json ├── public └── favicon.ico ├── script ├── build-prod.sh ├── copy-dependencies.js ├── deploy-lambda.js └── start.sh ├── server ├── render.js └── server.js ├── src ├── app │ └── app.js ├── bundles │ └── .gitkeep ├── client │ └── bootstrap.js ├── components │ ├── HomePage │ │ └── HomePage.js │ ├── HtmlDocument │ │ └── HtmlDocument.js │ └── app │ │ └── app.js └── router │ ├── route-helpers.js │ └── routes.js ├── styles ├── customizations │ └── customizations.less └── main.less ├── tag.sh └── webpack ├── dev.config.js ├── prod.config.js ├── server.js └── utils ├── notify-stats.js └── write-stats.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "optional": ["es7.classProperties"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/bundle 2 | src/bundles 3 | dist 4 | dist_static 5 | node_modules 6 | testinglambda.js 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "strict": 0, 5 | "indent": [ 6 | 2, 7 | 2 8 | ], 9 | "quotes": [ 10 | 2, 11 | "single" 12 | ], 13 | "linebreak-style": [ 14 | 2, 15 | "unix" 16 | ], 17 | "semi": [ 18 | 2, 19 | "always" 20 | ], 21 | "accessor-pairs": [2, {getWithoutSet: false}], 22 | "no-underscore-dangle": 0, 23 | "no-console": 0, 24 | "new-cap": 0, 25 | "comma-dangle": [2, "always-multiline"], 26 | "no-var": 2, 27 | "constructor-super": 2, 28 | "generator-star-spacing": [2, "before"], 29 | "no-this-before-super": 2, 30 | "object-shorthand": [2, "always"], 31 | "prefer-const": 2, 32 | "react/jsx-boolean-value": 2, 33 | "react/jsx-no-duplicate-props": [2, { "ignoreCase": false }], 34 | "react/jsx-no-undef": 2, 35 | "react/jsx-quotes": [1, "double", "avoid-escape"], 36 | "react/jsx-sort-prop-types": 0, 37 | "react/jsx-sort-props": 0, 38 | "react/jsx-uses-react": 2, 39 | "react/jsx-uses-vars": 2, 40 | "react/no-did-mount-set-state": 2, 41 | "react/no-did-update-set-state": 2, 42 | "react/no-multi-comp": 2, 43 | "react/no-unknown-property": 2, 44 | "react/prop-types": 2, 45 | "react/react-in-jsx-scope": 2, 46 | "react/require-extension": [1, { "extensions": [".js", ".jsx"] }], 47 | "react/self-closing-comp": 2, 48 | "react/sort-comp": [2, { 49 | "order": [ 50 | "lifecycle", 51 | "everything-else", 52 | "render" 53 | ], 54 | "groups": { 55 | "lifecycle": [ 56 | "displayName", 57 | "propTypes", 58 | "contextTypes", 59 | "childContextTypes", 60 | "defaultProps", 61 | "state", 62 | "constructor", 63 | "getChildContext", 64 | "componentWillMount", 65 | "componentDidMount", 66 | "componentWillReceiveProps", 67 | "shouldComponentUpdate", 68 | "componentWillUpdate", 69 | "componentDidUpdate", 70 | "componentWillUnmount" 71 | ] 72 | } 73 | }], 74 | "react/wrap-multilines": 2, 75 | "babel/object-shorthand": 1, 76 | "babel/generator-star-spacing": 1 77 | }, 78 | "env": { 79 | "es6": true, 80 | "browser": true, 81 | "node": true 82 | }, 83 | "ecmaFeatures": { 84 | "jsx": true, 85 | "generators": true, 86 | "arrowFunctions": true, 87 | "blockBindings": true, 88 | "classes": true, 89 | "defaultParams": true, 90 | "destructuring": true, 91 | "modules": true, 92 | "objectLiteralComputedProperties": true, 93 | "objectLiteralShorthandMethods": true, 94 | "objectLiteralShorthandProperties": true, 95 | "spread": true, 96 | "templateStrings": true, 97 | "binaryLiterals": true, 98 | "forOf": true, 99 | "modules": true, 100 | "octalLiterals": true, 101 | "regexUFlag": true, 102 | "regexYFlag": true, 103 | "restParams": true, 104 | "superInFunctions": true, 105 | "templateStrings": true, 106 | "unicodeCodePointEscapes": true 107 | }, 108 | "plugins": [ 109 | "react", 110 | "babel" 111 | ], 112 | "globals": { 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | public/bundle 6 | src/bundles/webpack-stats.json 7 | testinglambda.js 8 | dist_static 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-lambda-example 2 | ==================== 3 | 4 | Frontend application built with [React](https://facebook.github.io/react/) + [Webpack](webpack.github.io) + [BabelJS](https://babeljs.io) + [Less](http://lesscss.org/) + [Mocha](mochajs.org). 5 | 6 | This project serves as a proof of concept of creating a React application, utilizing server side rendering capabilities, which is hosted on Amazon Lambda, and exposed to world utilizing Amazon API Gateway. 7 | 8 | # Project description 9 | 10 | This section describes the architectural approaches to building this application. 11 | 12 | ## Application sources 13 | 14 | React is used as the main application framework, with the whole application split into the components. Sources for the main part of the application are stored inside the `src` directory. All sources, including those for the build pipeline are written using [BabelJS](https://babeljs.io) to take advantage of new ES features, as well as potential optimizations implemented as Babel plugins. 15 | 16 | [BabelJS](https://babeljs.io) is configured with the use of `.babelrc` file. It defines additional (experimental) options that are allowed within this repository. 17 | 18 | ## Build system 19 | 20 | Webpack is used as the main build tool responsible for compiling the sources written in BabelJS, Less and joining all files together. It has some internal magic as well, like making sure that files are split into chunks, css files contain vendor prefixes, names of the deployment packages are unique etc. It also sets up it's own development server with live reload possibilities, react components hot swapping and some other cool features. Most of that should be described in form of comments inside the configuration files for Webpack. Sources for those are available in the `webpack` directory. 21 | 22 | Project is also using "server-side rendering" of react components, so the HTML content is pre-generated and passed to the client side before starting the actual react application. 23 | 24 | This application has two different modes for handling server side rendering. One is implemented using ExpressJS, and is utilized for development purposes, where developers can use the application in the same way it is later used in production. 25 | 26 | Second one is a handler for Amazon Lambda, which is a simple pass through between server side rendering architecture, and Amazon Lambda input/output interface. To achieve this, application is packaged into a .ZIP archive as a build step, and uploaded to Amazon S3, from where it is picked up by Amazon Lambda. 27 | 28 | The ExpressJS module + the rendering component live inside the `server` directory. 29 | 30 | So after the webpack development server is started, all requests made to it, are proxied straight to the ExpressJS application to perform the server-side rendering of the react components based on the path specified in the URL. 31 | 32 | ## Styles 33 | 34 | The styles for the application are written in Less, and live inside the `styles` directory. Application style is mostly implemented utilizing a pre-made [bootstrap template](http://flatfull.com/themes/angulr/angular/#/app/dashboard-v1), with changes revolving mostly around applying the style to the layout designs. Customizations are kept in the `styles/customizations` directory, and should be kept in particular files named after the components that use them. Additionally some small style fixes might be kept in react components directly. Some assets other than the style files, are also kept in the `public` directory. 35 | 36 | ## Testing 37 | 38 | Project uses [Mocha](mochajs.org) framework for unit testing, with [Sinon](http://sinonjs.org/) and [Chai](chaijs.com) libraries for stubbing and assertions respectively. Tests are kept inside the `test` directory, with a structure of the test files reassembling the one from the `src` directory. 39 | 40 | ## Linting 41 | 42 | All application sources are linted using [Eslint](http://eslint.org/) which is configured with `.eslintrc` file to understand BabelJS sources as well as React JSX sources. Additionally project contains `.eslintignore` file which excludes the files that were generated by Webpack from linting. 43 | 44 | ## Continuous deployment 45 | 46 | This project is configured to use [CircleCI](https://circleci.com/) build system on all branches. Configuration of the continuous deployment is defined in the `circle.yml` file. It will run unit tests on each commit to the repository, and on merge to the *master* branch, it will perform deployment of the application to staging environment comprised of the following steps: 47 | - Build deployment package using webpack production configuration, 48 | - Deploy prepared package to Amazon S3 as a .ZIP archive named after the build number. This allows to keep the application versioned, and have builds immutable, 49 | - Tag current version of the code in the repository, with the current build number, 50 | - Trigger a code update inside Amazon Lambda to actually deploy new version of the code, to staging environment. 51 | 52 | # Running the application 53 | 54 | This section lists the required steps necessary to perform to get the application running in the development mode, as well as be able to prepare production packages by hand if necessary. 55 | 56 | ## Environment requirements 57 | 58 | This project requires [io.js](https://iojs.org/en/index.html) as some components are not compatible with NodeJS. I recommend using and installing it through [nvm](https://github.com/creationix/nvm), but in reality it doesn't matter how you will install it, as long as you are operating with the latest io.js binary. 59 | 60 | ## Installing dependencies 61 | 62 | Once your environment is configured, and you have the project files cloned, just go into the project directory and run command: 63 | 64 | ``` 65 | npm install 66 | ``` 67 | 68 | to download project dependencies. 69 | 70 | ## Prepare project package 71 | 72 | To create a `dist` directory with all the required files for the project, just run the command: 73 | 74 | ``` 75 | npm run release 76 | ``` 77 | 78 | ## Run application locally 79 | 80 | There is a command for running the prepared project package locally, simply run the command: 81 | 82 | ``` 83 | npm run serve 84 | ``` 85 | 86 | And open [http://localhost:9000](http://localhost:9000) in your browser, to see the application. 87 | 88 | ## Development mode 89 | 90 | To run the application in the development mode, with all the benefits of [Webpack](webpack.github.io) and its plugins, simply run the command: 91 | 92 | ``` 93 | npm start 94 | ``` 95 | 96 | This should automatically load the application in your browser. In case that doesn't happen, just go to [http://localhost:9000](http://localhost:9000). 97 | 98 | ## Lint all sources 99 | 100 | Linting of the sources is automatically performed when you start the project in the development mode, or when you build the production package, as well as before running the unit tests, but if you want to do perform just this task, simply run: 101 | 102 | ``` 103 | npm run lint 104 | ``` 105 | 106 | ## Run unit tests 107 | 108 | Tests are automatically ran when you build project package, but in case you want to run only tests, just run: 109 | 110 | ``` 111 | npm test 112 | ``` 113 | 114 | ## Generate unit test coverage report 115 | 116 | To generate the test coverage report, simply run: 117 | 118 | ``` 119 | npm run coverage 120 | ``` 121 | 122 | # Credits 123 | 124 | Parts of the code, mostly the ones related to server side rendering inside ExpressJS, and webpack configuration, are taken from very smart people at [GoCardless](https://github.com/gocardless/splash-pages). Thank you guys for sharing your knowledge! 125 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | --- 2 | machine: 3 | node: 4 | version: "iojs-v3.0.0" 5 | environment: 6 | S3_BUCKET: react-lambda-example 7 | AWS_REGION: us-east-1 8 | LAMBDA_FUNCTION_NAME: testFunction 9 | LAMBDA_BUCKET_NAME: react-lambda-example/lambda_versions 10 | STATIC_BUCKET_NAME: react-lambda-example/static_versions 11 | AWS_STATIC_DOMAIN: //d14uoqs6f7f5cn.cloudfront.net/static_versions/ 12 | test: 13 | override: 14 | - 15 | npm run release 16 | deployment: 17 | staging: 18 | branch: master 19 | commands: 20 | - 21 | npm run zip 22 | - 23 | npm run deploy 24 | - 25 | npm run deploy-static 26 | - 27 | git config --global user.email 'circleci@import.io' 28 | - 29 | git config --global user.name 'Circle CI' 30 | - 31 | ./tag.sh 32 | - 33 | npm run deploy-lambda 34 | -------------------------------------------------------------------------------- /config/dev-environment.js: -------------------------------------------------------------------------------- 1 | // Defines webpack development variables. 2 | export default { 3 | backendPort: '9000', 4 | webpackPort: '4402', 5 | }; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Run babel register to understand syntax supported by babel.js 2 | require('babel/register'); 3 | 4 | // Run main server script, to initialize express application serving content. 5 | require('./server/server'); 6 | -------------------------------------------------------------------------------- /lambda/index.js: -------------------------------------------------------------------------------- 1 | const main = require('./main'); 2 | exports.handler = main.mainHandler; 3 | -------------------------------------------------------------------------------- /lambda/main.js: -------------------------------------------------------------------------------- 1 | import co from 'co'; 2 | 3 | import { render } from './server/render'; 4 | 5 | /** 6 | * Transforms strange AWS properties syntax, into a proper JSON string. 7 | * @param {String} str String containing the JSON-like structure of parameters. 8 | * @return {String} Returns a JSON string obtained from reading the parameters. 9 | */ 10 | function JSONize(str) { 11 | return str 12 | .replace(/([\$\w]+)\s*:/g, function(_, $1){ return '"'+$1+'":'; }) 13 | .replace(/:([\$\w]+)\s*/g, function(_, $1) { return ':"'+$1+'"'; }) 14 | .replace(/'([^']+)'/g, function(_, $1){ return '"'+$1+'"'; }); 15 | } 16 | 17 | /** 18 | * Transforms strange AWS properties syntax, into a proper JS Object. 19 | * @param {String} str String containing the JSON-like structure of parameters. 20 | * @return {Object} Returns a JS Object obtained from reading the parameters. 21 | */ 22 | function parse(str) { 23 | return JSON.parse(JSONize(str.replace(/=/g, ':'))); 24 | } 25 | 26 | /** 27 | * Creates a full URL path from parameters obtained as input parameters to AWS Lambda function. 28 | * @param {Object} pathParts Object containing the list of path parameters eg. 29 | * {0: 'level1', 1: 'level2'} 30 | * @param {Object} queryString Object containing query string parameters that were passed to Lambda function, eg. 31 | * {param1: 'someValue', param2: 'otherValue'} 32 | * @return {String} Returns a full path of the request as it came from AWS API Gateway, for the examples above result would be: 33 | * '/level1/level2?param1=someValue¶m2=otherValue' 34 | */ 35 | function createFullPath(pathParts, queryString) { 36 | let path = '/'; 37 | 38 | path += Object.keys(pathParts).sort((a, b) => a - b).map(partKey => pathParts[partKey]).join('/'); 39 | 40 | const queryStringKeys = Object.keys(queryString); 41 | 42 | if(queryStringKeys.length > 0) { 43 | path += '?'; 44 | path += queryStringKeys.map(queryStringKey => `${queryStringKey}=${queryString[queryStringKey]}`).join('&'); 45 | } 46 | 47 | return path; 48 | } 49 | 50 | /** 51 | * Main Lambda function handler. It takes parameters passed from AWS API Gateway and returns generated HTML content, 52 | * based on the path of the request, and query string parameters. 53 | * @param {[type]} event Event object containing parameters passed from AWS API Gateway call. 54 | * @param {[type]} context Object used to return result from the AWS Lambda function. 55 | */ 56 | export const mainHandler = co.wrap(function *(event, context) { 57 | try { 58 | const path = parse(event.path); 59 | const querystring = parse(event.querystring); 60 | const fullPath = createFullPath(path, querystring); 61 | const [urlPath, renderedContent] = yield render(fullPath); 62 | context.succeed({urlPath, variableHTML: renderedContent}); 63 | } catch (error) { 64 | console.error('ERROR: ', error); 65 | context.succeed({error}); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lambda-example", 3 | "version": "0.1.0", 4 | "description": "React isomorphic application hosted on Amazon Lambda and served through Amazon API Gateway.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "./node_modules/.bin/eslint .", 8 | "start": "npm run lint && ./script/start.sh", 9 | "release": "npm run lint && ./script/build-prod.sh", 10 | "serve": "./node_modules/.bin/serve -p 9000 dist", 11 | "zip": "cd dist && zip -r ../$CIRCLE_BUILD_NUM.zip *", 12 | "deploy": "s3-deploy './*.zip' --cwd './' --region $AWS_REGION --bucket $LAMBDA_BUCKET_NAME", 13 | "deploy-static": "s3-deploy './dist_static/**' --cwd './dist_static/' --region $AWS_REGION --bucket $STATIC_BUCKET_NAME/$CIRCLE_BUILD_NUM", 14 | "deploy-lambda": "./script/deploy-lambda.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/domderen/react-lambda-example.git" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "Amazon", 23 | "Lambda", 24 | "API", 25 | "Gateway", 26 | "isomorphic", 27 | "universal", 28 | "server", 29 | "side", 30 | "rendering", 31 | "render", 32 | "string" 33 | ], 34 | "author": "Dominik Deren (@domderen)", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/domderen/react-lambda-example/issues" 38 | }, 39 | "homepage": "https://github.com/domderen/react-lambda-example#readme", 40 | "dependencies": { 41 | "co": "^4.6.0", 42 | "react": "^0.13.3", 43 | "react-router": "^0.13.3", 44 | "lodash": "^3.10.1", 45 | "immutable": "^3.7.4", 46 | "react-bootstrap": "^0.24.3", 47 | "babel": "^5.8.21", 48 | "babel-core": "^5.8.21" 49 | }, 50 | "devDependencies": { 51 | "autoprefixer-core": "^5.2.1", 52 | "aws-sdk": "^2.1.45", 53 | "babel-eslint": "^4.0.5", 54 | "babel-loader": "^5.3.2", 55 | "bootstrap": "^3.3.5", 56 | "compression": "^1.5.2", 57 | "css-loader": "^0.15.6", 58 | "denodeify": "^1.2.1", 59 | "eslint": "^1.1.0", 60 | "eslint-plugin-babel": "^2.0.0", 61 | "eslint-plugin-react": "^3.2.1", 62 | "express": "^4.13.3", 63 | "extract-text-webpack-plugin": "^0.8.2", 64 | "file-loader": "^0.8.4", 65 | "fs-extra": "^0.23.1", 66 | "less": "^2.5.1", 67 | "less-loader": "^2.2.0", 68 | "mkdirp": "^0.5.1", 69 | "node-libs-browser": "^0.5.2", 70 | "nodemon": "^1.4.0", 71 | "opn": "^3.0.2", 72 | "postcss-loader": "^0.5.1", 73 | "raw-loader": "^0.5.1", 74 | "react-hot-loader": "^1.2.8", 75 | "s3-deploy": "^0.1.7", 76 | "serve": "^1.4.0", 77 | "serve-favicon": "^2.3.0", 78 | "strip-loader": "^0.1.0", 79 | "style-loader": "^0.12.3", 80 | "url-loader": "^0.5.6", 81 | "webpack": "^1.11.0", 82 | "webpack-dev-server": "^1.10.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domderen/react-lambda-example/fa35f21c9c6bfeec0ae5fa3295400afd56601dc5/public/favicon.ico -------------------------------------------------------------------------------- /script/build-prod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Quit on errors. 4 | set -e errexit 5 | ulimit -n 10240 6 | 7 | # Cleans dist directory. 8 | rm -rf dist 9 | rm -rf dist_static 10 | # Runs webpack with production configuration. 11 | ANIMATIONS_DISABLED=$ANIMATIONS_DISABLED node_modules/.bin/webpack --stats --progress --config ./webpack/prod.config.js 12 | 13 | # Creates directories for production files 14 | mkdir dist 15 | mkdir dist_static 16 | 17 | # Compiles the sources required inside the AWS Lambda function. 18 | babel lambda --out-dir dist 19 | babel src --out-dir dist/src 20 | babel server --out-dir dist/server 21 | 22 | # Copies packages that are required to run AWS Lambda function (the ones that are marked as dependencies, as opposed to dev-dependencies). 23 | ./script/copy-dependencies.js 24 | 25 | # Copies webpack compilation results to AWS Lambda package. 26 | cp -R src/bundles dist/src/ 27 | 28 | # Copies static resources into a separete production directory. 29 | cp -R public/* dist_static 30 | -------------------------------------------------------------------------------- /script/copy-dependencies.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env babel-node 2 | 3 | import keys from 'lodash/object/keys'; 4 | import fs from 'fs-extra'; 5 | 6 | import packageJson from '../package.json'; 7 | 8 | keys(packageJson.dependencies).forEach(dependency => { 9 | try { 10 | const fromPath = `${__dirname}/../node_modules/${dependency}`; 11 | const toPath = `${__dirname}/../dist/node_modules/${dependency}`; 12 | console.log(`Copying dependency ${dependency} from ${fromPath} to ${toPath}`); 13 | fs.copySync(fromPath, toPath); 14 | } catch (e) { 15 | console.error(`Error while copying ${dependency}.`); 16 | console.error(e); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /script/deploy-lambda.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env babel-node 2 | 3 | import awsSdk from 'aws-sdk'; 4 | 5 | const lambda = new awsSdk.Lambda({apiVersion: '2015-03-31', region: process.env.AWS_REGION}); 6 | 7 | const params = { 8 | FunctionName: process.env.LAMBDA_FUNCTION_NAME, /* required */ 9 | S3Bucket: process.env.S3_BUCKET, 10 | S3Key: `lambda_versions/${process.env.CIRCLE_BUILD_NUM}.zip`, 11 | }; 12 | 13 | lambda.updateFunctionCode(params, function(err, data) { 14 | if (err) console.log(err, err.stack); // an error occurred 15 | else console.log(data); // successful response 16 | }); 17 | -------------------------------------------------------------------------------- /script/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: script/start 3 | # Starts the project's development server. 4 | 5 | # Quit on errors. 6 | set -e errexit 7 | 8 | ulimit -n 10240 9 | 10 | # Sets up watch on application files, that should restart express server that serves content, 11 | # Runs express server to serve content. 12 | ./node_modules/.bin/nodemon ./index.js \ 13 | --watch 'server' \ 14 | --watch 'webpack' & 15 | 16 | # Runs webpack development server. 17 | ./node_modules/.bin/babel-node ./webpack/server.js 18 | 19 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 20 | -------------------------------------------------------------------------------- /server/render.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import url from 'url'; 4 | import React from 'react'; 5 | import Router from 'react-router'; 6 | import result from 'lodash/object/result'; 7 | import findLast from 'lodash/collection/findLast'; 8 | 9 | import HtmlDocument from '../src/components/HtmlDocument/HtmlDocument'; 10 | import { getRoutes } from '../src/router/route-helpers'; 11 | 12 | /** 13 | * Returns a JSON array with paths to JS and CSS file, generated by webpack. 14 | */ 15 | function getWebpackPaths() { 16 | return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'src', 'bundles', 'webpack-stats.json'))); 17 | } 18 | 19 | /** 20 | * Cleans up the pathname part of the url. 21 | * @param {String} urlStr Url string to clean. 22 | * @return {String} Returns cleaned up url. 23 | */ 24 | function normalizeUrl(urlStr) { 25 | // Creates an object out of the url string. 26 | const parsedUrl = url.parse((urlStr || '').toLowerCase()); 27 | // Removes slashes at the beginning and end of pathname. 28 | parsedUrl.pathname = parsedUrl.pathname.replace(/^\/|\/$/g, ''); 29 | // Adds back slashes or replaces pathname with a single slash if it was falsy. 30 | parsedUrl.pathname = parsedUrl.pathname ? `/${parsedUrl.pathname}/` : '/'; 31 | // Returns back the url string. 32 | return url.format(parsedUrl); 33 | } 34 | 35 | export function render(requestUrl) { 36 | return new Promise(function(resolve, reject) { 37 | // Gets the normalized url. 38 | const reqUrl = normalizeUrl(requestUrl); 39 | // Get all routes config. 40 | const routes = getRoutes(); 41 | 42 | // Creates a new react-router object, with routes config, and current url. 43 | const router = Router.create({ 44 | onAbort(abortReason) { 45 | reject(abortReason); 46 | }, 47 | onError(err) { 48 | reject(err); 49 | }, 50 | routes, 51 | location: reqUrl, 52 | }); 53 | 54 | // Generates a handler for current route path. 55 | router.run(function(Handler, state) { 56 | // Gets the name of given route. 57 | const routeName = result(findLast(state.routes.slice(), 'name'), 'name'); 58 | // Generates new state properties. 59 | const stateProps = { 60 | routeName: routeName || 'home', 61 | pathname: state.pathname, 62 | }; 63 | 64 | // Generates the requested page markup. 65 | const markup = React.renderToString(); 66 | // Gets JS and CSS paths of files generated by webpack. 67 | const webpackUrls = getWebpackPaths(); 68 | 69 | // The application component is rendered to static markup 70 | // and sent as response. 71 | const html = React.renderToStaticMarkup( 72 | 79 | ); 80 | const doctype = ''; 81 | resolve([requestUrl, doctype + html]); 82 | }); 83 | }); 84 | } 85 | 86 | /** 87 | * Middle ware used to serve HTML content rendered based on the react-router path. 88 | * Content is rendered in form of the index.html page created as a react component. 89 | * @param {Object} req Express request object. 90 | * @param {Object} res Express response object. 91 | * @param {Function} next Callback passing control to the next handler. 92 | */ 93 | export function renderFromRequest(req, res, next) { 94 | // Checking if request is for HTML content. 95 | const isHtml = req.headers.accept && req.accepts('html'); 96 | 97 | // Skip not found assets 98 | // If not request for HTML content, pass control to the next handler. 99 | if (!isHtml) { return next(); } 100 | 101 | render(req.url) 102 | .then(([, htmlOutput]) => { 103 | res.send(htmlOutput); 104 | }) 105 | .catch(errorReason => { 106 | next(errorReason); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import http from 'http'; 3 | import express from 'express'; 4 | import favicon from 'serve-favicon'; 5 | import compression from 'compression'; 6 | 7 | import devEnv from '../config/dev-environment'; 8 | import { renderFromRequest } from './render'; 9 | 10 | /** 11 | * Normalizes the value of port number. 12 | * Parses provided value as integer of base 10, 13 | * If given value is not a number, returns originally provided value, 14 | * Otherwise if port number is bigger or equal to 0 returns parsed value. 15 | * In all other cases returns false. 16 | * @param {String} val String containing a port number to normalize. 17 | * @return {Number|Boolean} Returns a number if value was properly normalized, Otherwise returns false. 18 | */ 19 | function normalisePort(val) { 20 | const portNum = parseInt(val, 10); 21 | 22 | // named pipe 23 | if (Number.isNaN(portNum)) { return val; } 24 | if (portNum >= 0) { return portNum; } 25 | 26 | return false; 27 | } 28 | 29 | // Creates new express application. 30 | const app = express(); 31 | 32 | // Adds path where to serve a favicon request from. 33 | app.use(favicon(path.join(__dirname, '..', 'public', 'favicon.ico'))); 34 | 35 | // Serves all static files available at those locations from the root path. 36 | app.use(express.static(path.join(__dirname, '..', 'public'))); 37 | app.use(express.static(path.join(__dirname, '..', 'src'))); 38 | 39 | // Adds a page rendering middle ware, which renders a page based on 40 | // current url. Utilizes react rendering capabilities, and 41 | // react-router to render particular path page. 42 | app.use(renderFromRequest); 43 | 44 | // Middle ware for static content compression. 45 | app.use(compression()); 46 | 47 | // Sets the port on which application will be started. 48 | app.set('port', normalisePort(process.env.PORT || devEnv.backendPort)); 49 | 50 | // If we got to this point, it means that application was not able to handle the request, so it is time for error handling. 51 | // This one means that application was not able to find a suitable middle ware for the particular request. 52 | app.use(function(req, res, next) { 53 | const err = new Error('Not Found'); 54 | err.status = 404; 55 | next(err); 56 | }); 57 | 58 | // Function for logging application errors to console. 59 | function logErrors(err, req, res, next) { 60 | console.error(err.stack); 61 | next(err); 62 | } 63 | 64 | // Function responsible for handling errors for requests coming from AJAX libraries. 65 | function clientErrorHandler(err, req, res, next) { 66 | if (req.xhr) { 67 | res.status(err.status || 500).send({ error: 'Something blew up!' }); 68 | } else { 69 | next(err); 70 | } 71 | } 72 | 73 | // Function responsible for handling errors for all other requests. 74 | function errorHandler(err, req, res) { 75 | res.status(err.status || 500).send(` 76 |

${ err.message }

77 |

${ err.status }

78 |
${ err.stack }
79 | `); 80 | } 81 | 82 | // Adding error handling middle wares. 83 | app.use(logErrors); 84 | app.use(clientErrorHandler); 85 | app.use(errorHandler); 86 | 87 | // Creating server from express app. 88 | const server = http.createServer(app); 89 | 90 | // Stringifies current port or pipe value. 91 | function portType(portPipe) { 92 | return typeof portPipe === 'string' ? 'Pipe ' + portPipe : 'Port ' + portPipe; 93 | } 94 | 95 | // Defines on what port web server should be listening for requests. 96 | server.listen(app.get('port')); 97 | 98 | // Web server error handling. 99 | server.on('error', function onError(error) { 100 | if (error.syscall !== 'listen') { 101 | throw error; 102 | } 103 | 104 | const bind = portType(app.get('port')); 105 | 106 | // handle specific listen errors with friendly messages 107 | switch (error.code) { 108 | case 'EACCES': 109 | console.error(bind + ' requires elevated privileges'); 110 | process.exit(1); // eslint-disable-line no-process-exit 111 | break; 112 | case 'EADDRINUSE': 113 | console.error(bind + ' is already in use'); 114 | process.exit(1); // eslint-disable-line no-process-exit 115 | break; 116 | default: 117 | throw error; 118 | } 119 | }); 120 | 121 | // Notification displaying on which port and under what port application is listening. 122 | server.on('listening', function onListening() { 123 | const addr = server.address(); 124 | const bind = typeof addr === 'string' ? addr : addr.port; 125 | const type = portType(bind); 126 | console.log(`Listening on http:\/\/localhost:${bind} (${type})`); 127 | }); 128 | -------------------------------------------------------------------------------- /src/app/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is main application component, the one that will always be rendered as wrapper around RouteHandler. 3 | */ 4 | import 'babel/polyfill'; 5 | import React from 'react'; 6 | import { RouteHandler } from 'react-router'; 7 | import Grid from 'react-bootstrap/lib/Grid'; 8 | import Row from 'react-bootstrap/lib/Row'; 9 | import Col from 'react-bootstrap/lib/Col'; 10 | 11 | class App extends React.Component { 12 | static propTypes = { 13 | routeName: React.PropTypes.string.isRequired, 14 | pathname: React.PropTypes.string.isRequired, 15 | } 16 | 17 | static childContextTypes = { 18 | routeName: React.PropTypes.string.isRequired, 19 | pathname: React.PropTypes.string.isRequired, 20 | } 21 | 22 | getChildContext() { 23 | const { routeName, pathname } = this.props; 24 | 25 | return { 26 | routeName, 27 | pathname, 28 | }; 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | } 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /src/bundles/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domderen/react-lambda-example/fa35f21c9c6bfeec0ae5fa3295400afd56601dc5/src/bundles/.gitkeep -------------------------------------------------------------------------------- /src/client/bootstrap.js: -------------------------------------------------------------------------------- 1 | import 'babel/polyfill'; 2 | import result from 'lodash/object/result'; 3 | import findLast from 'lodash/collection/findLast'; 4 | import React from 'react'; 5 | import Router from 'react-router'; 6 | 7 | import { getRoutes } from '../router/route-helpers'; 8 | 9 | // This is used with webpack, but not with pages requested trough the express server. 10 | if (process.env.BROWSER) { 11 | require('../../styles/main.less'); 12 | } 13 | 14 | // Take the application state from the global variable defined on the server side. 15 | const appState = window.app; 16 | // Gets the react-router config. 17 | const routes = getRoutes(); 18 | // Creates new instance of the router to live on the client side. 19 | const router = Router.create({ 20 | routes, 21 | location: Router.HistoryLocation, 22 | }); 23 | 24 | // Initializes react router, and runs it for a particular handler. 25 | router.run(function(Handler, state) { 26 | // Find the root element where react should mount itself. 27 | const mountNode = document.getElementById('root'); 28 | // Get the name of the current route handler. 29 | const routeName = result(findLast(state.routes.slice(), 'name'), 'name'); 30 | // Build application state object. 31 | const stateProps = Object.assign({}, appState, { 32 | routeName: routeName || 'not_found', 33 | pathname: state.pathname, 34 | }); 35 | 36 | // Bootstraps react to the mount node, and performs an update on it and only mutate the DOM 37 | // as necessary to reflect the latest React component. 38 | React.render(, mountNode, () => { 39 | console.log('App has been mounted.'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/HomePage/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class HomePage extends React.Component { 4 | render() { 5 | return ( 6 |
Home Page after major refactoring!
7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/HtmlDocument/HtmlDocument.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class HtmlDocument extends React.Component { 4 | // Components properties with types and requirement. 5 | static propTypes = { 6 | routeName: React.PropTypes.string.isRequired, 7 | router: React.PropTypes.func.isRequired, 8 | pathname: React.PropTypes.string.isRequired, 9 | dataRender: React.PropTypes.object.isRequired, 10 | markup: React.PropTypes.string.isRequired, 11 | script: React.PropTypes.arrayOf(React.PropTypes.string), 12 | css: React.PropTypes.arrayOf(React.PropTypes.string), 13 | } 14 | 15 | // Properties that are passed to all child components. 16 | static childContextTypes = { 17 | routeName: React.PropTypes.string.isRequired, 18 | router: React.PropTypes.func.isRequired, 19 | pathname: React.PropTypes.string.isRequired, 20 | }; 21 | 22 | // Default properties. 23 | static defaultProps = { 24 | script: [], 25 | css: [], 26 | } 27 | 28 | // Values of properties that will be passed to all child components. 29 | getChildContext() { 30 | const { routeName, router, pathname } = this.props; 31 | 32 | return { 33 | routeName, 34 | router, 35 | pathname, 36 | }; 37 | } 38 | 39 | render() { 40 | const { markup, script, css } = this.props; 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | { /* Page title. */ } 49 | React-Lambda-Example 50 | 51 | { /* Listing all css files from webpack. */ } 52 | { css.map((href, k) => ) } 53 | 54 | 55 | 56 | { /* Adds markup for a given route element. */ } 57 |
58 | 59 | { /* Sets application state into a global variable. */ } 60 |