├── .eslintrc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── README.md ├── index.es6.js ├── package.json ├── src ├── client.es6.js └── server.es6.js └── test └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "root": true, 5 | 6 | "env": { 7 | // I write for browser 8 | "browser": true, 9 | // in CommonJS 10 | "node": true 11 | }, 12 | 13 | "extends": "eslint:recommended", 14 | 15 | // plugins let use use custom rules below 16 | "plugins": [ 17 | "babel", 18 | "react" 19 | ], 20 | 21 | "ecmaFeatures": { 22 | "jsx": true, 23 | "es6": true, 24 | }, 25 | 26 | "rules": { 27 | // 0 = off 28 | // 1 = warn 29 | // 2 = error 30 | 31 | // React specifc rules 32 | "react/jsx-boolean-value": 0, 33 | "react/jsx-closing-bracket-location": 1, 34 | "react/jsx-curly-spacing": [2, "always"], 35 | "react/jsx-indent-props": [1, 2], 36 | "react/jsx-no-undef": 1, 37 | "react/jsx-uses-react": 1, 38 | "react/jsx-uses-vars": 1, 39 | "react/wrap-multilines": 1, 40 | "react/react-in-jsx-scope": 1, 41 | "react/prefer-es6-class": 1, 42 | // no binding functions in render for perf 43 | "react/jsx-no-bind": 1, 44 | 45 | // handle async/await functions correctly 46 | // "babel/generator-star-spacing": 1, 47 | // handle object spread 48 | "babel/object-shorthand": 1, 49 | 50 | "indent": [2, 2, {"SwitchCase": 1}], 51 | "max-len": [1, 100, 2, {"ignoreComments": true}], 52 | "no-unused-vars": 1, 53 | "no-console": 0, 54 | // semicolons 55 | "semi": [2, "always"], 56 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 57 | "comma-dangle": [2, "always-multiline"], 58 | "consistent-return": 0, 59 | "no-underscore-dangle": 0, 60 | "quotes": [2, "single"], 61 | "space-after-keywords": [2, "always"], 62 | "space-before-blocks": [2, "always"], 63 | "space-before-function-parentheses": [0, "never"], 64 | "space-in-brackets": [0, "never"], 65 | "space-in-parens": [2, "never"], 66 | "space-return-throw-case": 1, 67 | "space-unary-ops": [1, { "words": true, "nonwords": false }], 68 | "strict": [2, "never"], 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | start.sh 31 | 32 | workspace.json 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4" 5 | - "5" 6 | 7 | script: 8 | # - npm run lint 9 | - npm run test 10 | 11 | branches: 12 | except: 13 | - staging 14 | 15 | env: 16 | - CXX=g++-4.8 17 | 18 | addons: 19 | apt: 20 | sources: 21 | - ubuntu-toolchain-r-test 22 | packages: 23 | - g++-4.8 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to horse-react 2 | =========================== 3 | 4 | So you want to contribute to horse-react? Fantastic! Here's a brief overview on 5 | how best to do so. 6 | 7 | ## What to change 8 | 9 | Here's some examples of things you might want to make a pull request for: 10 | 11 | * New features 12 | * Bugfixes 13 | * Inefficient blocks of code 14 | 15 | If you have a more deeply-rooted problem with how the program is built or some 16 | of the stylistic decisions made in the code, it's best to 17 | [create an issue](https://github.com/reddit/horse-react/issues) before putting 18 | the effort into a pull request. The same goes for new features - it is 19 | best to check the project's direction, existing pull requests, and currently open 20 | and closed issues first. 21 | 22 | ## Style 23 | 24 | * Two spaces, not tabs 25 | * Semicolons are not optional 26 | * All pages should render on the server and the client. The site should be 27 | usable without javascript. 28 | * Review our [style guide](https://github.com/reddit/tree/master/javascript) for 29 | more information. 30 | 31 | Look at existing code to get a good feel for the patterns we use. Please run 32 | tests before submitting any pull requests. Instructions for running tests can 33 | be found in the README. 34 | 35 | ## Using Git appropriately 36 | 37 | 1. [Fork the repository](https://github.com/reddit/horse-react/fork_select) to 38 | your Github account. 39 | 2. Create a *topical branch* - a branch whose name is succint but explains what 40 | you're doing, such as "change-orangered-to-periwinkle" 41 | 3. Make your changes, committing at logical breaks. 42 | 4. Push your branch to your personal account 43 | 5. [Create a pull request](https://help.github.com/articles/using-pull-requests) 44 | 6. Watch for comments or acceptance 45 | 46 | Please make separate branches for unrelated changes! 47 | 48 | ## Licensing 49 | 50 | horse-react is MIT licensed. See details in the LICENSE file. This is a very permissive 51 | scheme, GPL-compatible but without many of the restrictions of GPL. 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | horse-react 2 | =========== 3 | 4 | [![Build Status](https://travis-ci.org/reddit/horse-react.svg?branch=master)](https://travis-ci.org/reddit/horse-react) 5 | 6 | horse-react is an implementation of the 7 | [horse](https://github.com/reddit/horse) application building library, with 8 | some helpers to make working with react easily. 9 | 10 | Go check out that documentation, then return to see how you'd use it with 11 | React a little easier. 12 | 13 | New APIs 14 | -------- 15 | 16 | `horse-react` exposes pre-built `render` and `error` functions that you can 17 | hook into, through `ClientApp` and `ServerApp` classes. It expects your 18 | middleware to attach a `layout`, `body`, and `props` property to the `context` 19 | object during the course of your route handling, and at the end, it will render 20 | it out (either with `layout`, if on the server, or it will mount the `body` if 21 | on the client.) 22 | 23 | 24 | A Brief Overview 25 | ---------------- 26 | 27 | An example usage might be like: (es6 incoming) 28 | 29 | `routes.es6.js` 30 | 31 | ```javascript 32 | // This is used both client- and server- side, and simply sets up an app with 33 | // routes; in this case, returning React elements. 34 | 35 | import Layout from '../layouts/layout.jsx'; 36 | import Index from '../pages/index.jsx'; 37 | 38 | function setupRoutes(app) { 39 | app.router.get('/', function *(next) { 40 | this.data = new Map({ 41 | user: db.getUser(1) 42 | }); 43 | 44 | this.layout = Layout; 45 | 46 | this.body = function(props) { 47 | return ; 48 | }); 49 | }); 50 | } 51 | 52 | export default setupRoutes; 53 | ``` 54 | 55 | 56 | `server.es6.js` 57 | 58 | ```javascript 59 | import koa from 'koa'; 60 | 61 | import {ServerReactApp} from 'horse-react'; 62 | import setupRoutes from './setupRoutes'; 63 | 64 | var server = koa(); 65 | 66 | var app = new App(); 67 | setupRoutes(app); 68 | 69 | server.use(ServerReactApp.serverRender(app)); 70 | ``` 71 | 72 | `client.es6.js` 73 | 74 | You'll want to add push state too, but that's outside the scope of our 75 | example. 76 | 77 | ```javascript 78 | import React from 'react'; 79 | import {ClientReactApp} from 'horse-react'; 80 | 81 | import setupRoutes from './setupRoutes'; 82 | 83 | import jQuery as $ from 'jquery'; 84 | 85 | var app = new ClientApp({ 86 | mountPoint: document.getElementById('app-container') 87 | }); 88 | 89 | setupRoutes(app); 90 | 91 | $(function() { 92 | $('body').on('click', 'a', function(e) { 93 | e.preventDefault(); 94 | app.render(this.href); 95 | }); 96 | }); 97 | ``` 98 | 99 | Additional Notes 100 | ---------------- 101 | 102 | If you want to mount a client application directly on the server-rendered 103 | markup, add `this.staticMarkup` to the context before `serverRender` is called. 104 | Your `layout` should include `!!CONTENT!!` as the magic word where rendered 105 | body markup should be inserted (instead of `{this.children}`.) 106 | -------------------------------------------------------------------------------- /index.es6.js: -------------------------------------------------------------------------------- 1 | import ClientReactApp from './src/client'; 2 | import ServerReactApp from './src/server'; 3 | 4 | export {ClientReactApp, ServerReactApp} 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@r/horse-react", 3 | "version": "1.3.0", 4 | "description": "react-specific implementation of `horse`", 5 | "main": "index.es6.js", 6 | "scripts": { 7 | "test": "./node_modules/mocha/bin/mocha test/index.js", 8 | "lint": "eslint . --ext .js,.jsx" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/reddit/horse-react.git" 13 | }, 14 | "author": "reddit ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/reddit/horse-react/issues" 18 | }, 19 | "homepage": "https://github.com/reddit/horse-react", 20 | "dependencies": { 21 | "horse": "^1.3.2", 22 | "react": "^0.14.7", 23 | "react-dom": "^0.14.7" 24 | }, 25 | "devDependencies": { 26 | "babel": "^5.0.0", 27 | "babel-plugin-syntax-trailing-function-commas": "^6.5.0", 28 | "babel-plugin-transform-async-to-generator": "^6.7.4", 29 | "babel-plugin-transform-class-properties": "^6.6.0", 30 | "babel-plugin-transform-object-rest-spread": "^6.6.5", 31 | "babel-plugin-transform-react-constant-elements": "^6.5.0", 32 | "babel-plugin-transform-react-inline-elements": "^6.6.5", 33 | "babel-preset-es2015": "^6.6.0", 34 | "babel-preset-react": "^6.5.0", 35 | "chai": "^2.1.0", 36 | "eslint": "^1.10.3", 37 | "mocha": "^2.1.0", 38 | "sinon": "^1.12.2", 39 | "sinon-chai": "^2.7.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/client.es6.js: -------------------------------------------------------------------------------- 1 | import React from 'react-dom'; 2 | import { ClientApp } from 'horse'; 3 | 4 | class ClientReactApp extends ClientApp { 5 | constructor (props={}) { 6 | super(props); 7 | 8 | if (props.mountPoint) { 9 | this.mountPoint = props.mountPoint; 10 | } 11 | 12 | if (window.bootstrap) { 13 | this.resetState(window.bootstrap); 14 | } 15 | 16 | this.redirect = this.redirect.bind(this); 17 | } 18 | 19 | redirect (status, path) { 20 | if ((typeof status === 'string') && !path) { 21 | path = status; 22 | } 23 | 24 | this.render(path); 25 | } 26 | 27 | buildContext (href) { 28 | const request = this.buildRequest(href); 29 | 30 | // `this` binding, how does it work 31 | return { 32 | ...request, 33 | redirect: this.redirect, 34 | error: this.error, 35 | }; 36 | } 37 | 38 | render (href, firstLoad, modifyContext) { 39 | var mountPoint = this.mountPoint; 40 | 41 | if (!mountPoint) { 42 | throw('Please define a `mountPoint` on your ClientApp for the react element to render to.'); 43 | } 44 | 45 | var ctx = this.buildContext(href); 46 | 47 | if (modifyContext) { 48 | var ctx = modifyContext(ctx); 49 | } 50 | 51 | if (firstLoad) { 52 | ctx.props = this.getState(); 53 | } 54 | 55 | return new Promise((resolve) => { 56 | this.route(ctx).then(() => { 57 | if (ctx.body && typeof ctx.body === 'function') { 58 | try { 59 | this.emitter.once('page:update', resolve); 60 | React.render(ctx.body(ctx.props), mountPoint); 61 | } catch (e) { 62 | this.error(e, ctx, this); 63 | } 64 | } 65 | }); 66 | }); 67 | } 68 | } 69 | 70 | export default ClientReactApp; 71 | -------------------------------------------------------------------------------- /src/server.es6.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/server'; 3 | import { App } from 'horse'; 4 | 5 | class ServerReactApp extends App { 6 | injectBootstrap (format) { 7 | return function * () { 8 | this.props.timings = this.timings; 9 | 10 | var p = Object.assign({}, this.props); 11 | 12 | if (format) { 13 | p = format(p); 14 | } 15 | 16 | delete p.app; 17 | delete p.api; 18 | delete p.manifest; 19 | 20 | const nonce = p.nonce; 21 | delete p.nonce; 22 | 23 | p.data = {}; 24 | 25 | var bootstrap = ServerReactApp.safeStringify(p); 26 | 27 | var body = this.body; 28 | var bodyIndex = body.lastIndexOf(''); 29 | var template = ``; 30 | this.body = body.slice(0, bodyIndex) + template + body.slice(bodyIndex); 31 | } 32 | } 33 | 34 | * render () { 35 | if (typeof this.body === 'function') { 36 | var Layout = this.layout; 37 | var props = this.props; 38 | this.type = 'text/html; charset=utf-8'; 39 | 40 | try { 41 | if (this.staticMarkup) { 42 | var layout = ReactDOM.renderToStaticMarkup(); 43 | var body = ReactDOM.renderToString(this.body(props)); 44 | 45 | this.body = layout.replace(/!!CONTENT!!/, body); 46 | } else { 47 | this.body = ReactDOM.renderToStaticMarkup( 48 | 49 | { this.body(props) } 50 | 51 | ); 52 | } 53 | } catch (e) { 54 | this.props.app.error(e, this, this.props.app); 55 | yield this.props.app.render; 56 | } 57 | } 58 | } 59 | 60 | * loadData() { 61 | // this.props.data is a map; pass in its keys as an array of promises 62 | if (this.props.data) { 63 | return Promise.all([...this.props.data.values()].map(f => f())); 64 | } else { 65 | return Promise.resolve(); 66 | } 67 | } 68 | 69 | static safeStringify (obj) { 70 | return JSON.stringify(obj) 71 | .replace(/&/g, '\\u0026') 72 | .replace(//g, '\\u003E'); 74 | } 75 | 76 | static serverRender (app, formatProps) { 77 | return function * () { 78 | this.timings = {}; 79 | 80 | if (this.accepts('html')) { 81 | var routeStart = Date.now(); 82 | yield app.route(this); 83 | this.timings.route = Date.now() - routeStart; 84 | } 85 | 86 | if (typeof this.body === 'function') { 87 | // Load all the data required for the request before the server renders 88 | this.props = this.props || {}; 89 | this.props.dataCache = {}; 90 | 91 | if (!this.skipServerPreload) { 92 | var data; 93 | 94 | try { 95 | var dataStart = Date.now(); 96 | data = yield app.loadData; 97 | this.timings.data = Date.now() - dataStart; 98 | } catch (e) { 99 | app.error(e, this, app); 100 | } 101 | 102 | 103 | if (data) { 104 | // The entries are in the same order as when we fired off the promises; 105 | // load the data from the response array. 106 | var i = 0; 107 | for (var [key, value] of this.props.data.entries()) { 108 | this.props.dataCache[key] = data[i]; 109 | i++; 110 | } 111 | } 112 | } 113 | 114 | if (this.preServerRender) { 115 | const preServerRender = this.preServerRender(this); 116 | 117 | // If you explicitly return `false`, don't continue the render. 118 | if (preServerRender === false) { 119 | return; 120 | } 121 | } 122 | 123 | var renderStart = Date.now(); 124 | yield app.render; 125 | this.timings.render = Date.now() - renderStart; 126 | 127 | if (formatProps) { 128 | this.props = formatProps(this.props); 129 | } 130 | 131 | yield app.injectBootstrap(app.config.formatBootstrap); 132 | } 133 | } 134 | } 135 | } 136 | 137 | export default ServerReactApp; 138 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require('babel/register')({ 2 | extensions: ['.js', '.es6.js'], 3 | }); 4 | --------------------------------------------------------------------------------