├── .babelrc ├── .eslintrc ├── .github └── workflows │ ├── node-aught.yml │ ├── node-pretest.yml │ ├── node-tens.yml │ ├── rebase.yml │ └── require-allow-edits.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .nycrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── client-spec.md ├── clients.md ├── hypernova-how-it-works.png └── markup.md ├── examples └── simple │ ├── .gitignore │ ├── Gemfile │ ├── Gemfile.lock │ ├── README.md │ ├── README.rdoc │ ├── Rakefile │ ├── app │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── MyComponent.js │ │ │ ├── application.js │ │ │ └── welcome.coffee │ │ └── stylesheets │ │ │ ├── application.css │ │ │ └── welcome.scss │ ├── controllers │ │ ├── application_controller.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── welcome_controller.rb │ ├── helpers │ │ ├── application_helper.rb │ │ └── welcome_helper.rb │ ├── mailers │ │ └── .keep │ ├── models │ │ ├── .keep │ │ └── concerns │ │ │ └── .keep │ └── views │ │ ├── layouts │ │ └── application.html.erb │ │ └── welcome │ │ └── index.html.erb │ ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ └── spring │ ├── config.ru │ ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── hypernova.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── routes.rb │ └── secrets.yml │ ├── db │ └── seeds.rb │ ├── hypernova.js │ ├── log │ └── .keep │ ├── package.json │ ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── favicon.ico │ └── robots.txt │ ├── test │ ├── controllers │ │ ├── .keep │ │ └── welcome_controller_test.rb │ ├── fixtures │ │ └── .keep │ ├── helpers │ │ └── .keep │ ├── integration │ │ └── .keep │ ├── mailers │ │ └── .keep │ ├── models │ │ └── .keep │ └── test_helper.rb │ └── vendor │ └── assets │ ├── javascripts │ └── .keep │ └── stylesheets │ └── .keep ├── package.json ├── server.js ├── src ├── Module.js ├── coordinator.js ├── createGetComponent.js ├── createVM.js ├── environment.js ├── getFiles.js ├── index.js ├── loadModules.js ├── server.js ├── utils │ ├── BatchManager.js │ ├── lifecycle.js │ ├── logger.js │ └── renderBatch.js └── worker.js └── test ├── BatchManager-test.js ├── Module-test.js ├── a.js ├── b.js ├── checkIso.js ├── client-test.js ├── components ├── HypernovaExample.js └── nested │ └── component.bundle.js ├── coordinator-test.js ├── createGetComponent-test.js ├── createVM-test.js ├── escape-test.js ├── getFiles-test.js ├── helper.js ├── hypernova-runner-test.js ├── index-test.js ├── init.js ├── lifecycle-test.js ├── loadModules-test.js ├── mutableArray.js ├── renderBatch-test.js └── server-test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["airbnb"], 3 | "plugins": [ 4 | "add-module-exports", 5 | ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "airbnb-base", 4 | "env": { 5 | "node": true, 6 | }, 7 | 8 | "rules": { 9 | "function-paren-newline": 0, 10 | "no-console": 0, 11 | "no-underscore-dangle": 1, 12 | "object-curly-newline": 0, 13 | "max-len": 0, 14 | }, 15 | 16 | "overrides": [ 17 | { 18 | "files": "test/**/*", 19 | "env": { 20 | "mocha": true, 21 | }, 22 | "rules": { 23 | "indent": 1, 24 | "prefer-promise-reject-errors": 0, 25 | }, 26 | }, 27 | { 28 | "files": "test/components/**/*", 29 | "rules": { 30 | "object-shorthand": 0, 31 | "func-names": 0, 32 | }, 33 | }, 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/node-aught.yml: -------------------------------------------------------------------------------- 1 | name: 'Tests: node.js < 10' 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | tests: 7 | uses: ljharb/actions/.github/workflows/node.yml@main 8 | with: 9 | range: '>= 4 < 10' # node < 4 breaks due to babel; we'll need staged builds for this 10 | type: majors 11 | command: npm run tests-only 12 | skip-ls-check: true 13 | 14 | node: 15 | name: 'node < 10' 16 | needs: [tests] 17 | runs-on: ubuntu-latest 18 | steps: 19 | - run: 'echo tests completed' 20 | -------------------------------------------------------------------------------- /.github/workflows/node-pretest.yml: -------------------------------------------------------------------------------- 1 | name: 'Tests: pretest/posttest' 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | tests: 7 | uses: ljharb/actions/.github/workflows/pretest.yml@main 8 | -------------------------------------------------------------------------------- /.github/workflows/node-tens.yml: -------------------------------------------------------------------------------- 1 | name: 'Tests: node.js >= 10' 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | tests: 7 | uses: ljharb/actions/.github/workflows/node.yml@main 8 | with: 9 | range: '>= 10' 10 | type: majors 11 | command: npm run tests-only 12 | 13 | node: 14 | name: 'node >= 10' 15 | needs: [tests] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - run: 'echo tests completed' 19 | -------------------------------------------------------------------------------- /.github/workflows/rebase.yml: -------------------------------------------------------------------------------- 1 | name: Automatic Rebase 2 | 3 | on: [pull_request_target] 4 | 5 | jobs: 6 | _: 7 | name: "Automatic Rebase" 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ljharb/rebase@master 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/require-allow-edits.yml: -------------------------------------------------------------------------------- 1 | name: Require “Allow Edits” 2 | 3 | on: [pull_request_target] 4 | 5 | jobs: 6 | _: 7 | name: "Require “Allow Edits”" 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: ljharb/require-allow-edits@main 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | TODO 36 | 37 | # coverage 38 | .nyc_output 39 | coverage 40 | 41 | # build output 42 | lib 43 | 44 | # Only apps should have lockfiles 45 | yarn.lock 46 | npm-shrinkwrap.json 47 | package-lock.json 48 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | TODO 36 | 37 | # coverage 38 | .nyc_output 39 | coverage 40 | 41 | examples 42 | docs 43 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | audit-level=critical 3 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "check-coverage": false, 4 | "reporter": ["text-summary", "text", "html", "json"], 5 | "lines": 86, 6 | "statements": 85.93, 7 | "functions": 82.43, 8 | "branches": 76.06, 9 | "exclude": [ 10 | "coverage", 11 | "test" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [2.5.0] - 2019-01-02 7 | 8 | ### Added 9 | - worker: Add graceful shutdown (#147, #148) 10 | 11 | ## [2.4.0] - 2018-08-04 12 | 13 | ### Added 14 | - Add option to pass an express instance in the configuration (#132) 15 | 16 | ### Docs 17 | - Update README.md: correctly use curly quotation marks (#143) 18 | 19 | ## [2.3.0] - 2018-08-04 20 | 21 | ### Added 22 | - [deps] allow `airbnb-js-shims v2 or v3 23 | 24 | ## [2.2.6] - 2018-05-10 25 | 26 | ### Added 27 | 28 | - Allow logger instance to be injected 29 | 30 | ## [2.2.5] - 2018-04-05 31 | 32 | ### Added 33 | 34 | - Handle timeout in coordinator shutdown to kill workers that have not shut down. 35 | 36 | ## [2.2.4] - 2018-03-20 37 | 38 | ### Changed 39 | 40 | - Refactor server/worker configuration into smaller pieces to be exported 41 | 42 | ## [2.2.3] - 2018-03-01 43 | 44 | ### Changed 45 | 46 | - Clear timeout set in raceTo 47 | 48 | ## [2.2.2] - 2018-02-26 49 | 50 | ### Added 51 | 52 | - Option to execute jobs in a batch serially, rather than concurrently 53 | 54 | ## [2.2.1] - 2018-02-26 55 | 56 | Bit of a flub with dist-tags, skipped version 57 | 58 | ## [2.2.0] - 2017-10-06 59 | 60 | ### Changed 61 | 62 | - If no HTML is returned from the render function then Hypernova will reject the Promise. 63 | 64 | ## [2.1.3] - 2017-06-16 65 | 66 | ### Added 67 | 68 | - Number of CPUs is now configurable. 69 | - Host is configurable. 70 | 71 | 72 | ## [2.1.1] - 2017-06-15 73 | 74 | ### Changed 75 | 76 | - You may now return a Promise from the top-level render function. 77 | 78 | 79 | ## [2.0.0] - 2016-09-15 80 | 81 | ### Breaking Changes 82 | 83 | - `toScript` function signature changed. It now expects an object of data attributes to value. 84 | 85 | ```js 86 | // before 87 | toScript('foo', 'bar', { hello: 'world' }) 88 | 89 | // now 90 | toScript({ foo: 'bar' }, { hello: 'world' }) 91 | ``` 92 | 93 | - `fromScript` function signature changed. 94 | 95 | ```js 96 | // before 97 | fromScript('foo', 'bar') 98 | 99 | // now 100 | fromScript({ foo: 'bar' }) 101 | ``` 102 | 103 | ## [1.2.0] - 2016-09-08 104 | 105 | ### Changed 106 | 107 | - Exceptions that are not Errors are no longer wrapped in an Error so the stack trace does not 108 | include the Hypernova callsite. 109 | 110 | ### Added 111 | 112 | - Passing in `context` into `getComponent` which contains things like the `props` that the 113 | component will receive. 114 | 115 | ## [1.1.0] - 2016-06-15 116 | 117 | ### Changed 118 | 119 | - Documentation fixes. 120 | - Allows non-errors to be rejected from Promises in getComponent. 121 | - Sets worker count to 1 when cpu count is 1. 122 | - Makes the endpoint configurable. 123 | - Exports worker functions so you can customize your own worker. 124 | 125 | ## [1.0.0] - 2016-06-06 126 | 127 | Initial Release 128 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Airbnb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | | :exclamation: Deprecation Notice | 2 | |:-| 3 | |We want to express our sincere gratitude for your support and contributions to the Hypernova open source project. As we are no longer using this technology internally, we have come to the decision to archive the Hypernova repositories. While we won't be providing further updates or support, the existing code and resources will remain accessible for your reference. We encourage anyone interested to fork the repository and continue the project's legacy independently. Thank you for being a part of this journey and for your patience and understanding.| 4 | --- 5 | 6 | # Hypernova 7 | 8 | > A service for server-side rendering your JavaScript views 9 | 10 | [![Join the chat at https://gitter.im/airbnb/hypernova](https://badges.gitter.im/airbnb/hypernova.svg)](https://gitter.im/airbnb/hypernova?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 11 | 12 | [![NPM version](https://badge.fury.io/js/hypernova.svg)](http://badge.fury.io/js/hypernova) 13 | [![Build Status](https://secure.travis-ci.org/airbnb/hypernova.svg?branch=master)](http://travis-ci.org/airbnb/hypernova) 14 | [![Dependency Status](https://david-dm.org/airbnb/hypernova.svg)](https://david-dm.org/airbnb/hypernova) 15 | 16 | ## Why? 17 | 18 | First and foremost, server-side rendering is a better user experience compared to just client-side rendering. The user gets the content faster, the webpage is more accessible when JS fails or is disabled, and search engines have an easier time indexing it. 19 | 20 | Secondly, it provides a better developer experience. Writing the same markup twice both on the server in your preferred templating library and in JavaScript can be tedious and hard to maintain. Hypernova lets you write all of your view code in a single place without having to sacrifice the user’s experience. 21 | 22 | ## How? 23 | 24 | ![Diagram that visually explains how hypernova works](docs/hypernova-how-it-works.png) 25 | 26 | 1. A user requests a page on your server. 27 | 1. Your server then gathers all the data it needs to render the page. 28 | 1. Your server uses a Hypernova client to submit an HTTP request to a Hypernova server. 29 | 1. Hypernova server computes all the views into an HTML string and sends them back to the client. 30 | 1. Your server then sends down the markup plus the JavaScript to the browser. 31 | 1. On the browser, JavaScript is used to progressively enhance the application and make it dynamic. 32 | 33 | ## Terminology 34 | 35 | * **hypernova/server** - Service that accepts data via HTTP request and responds with HTML. 36 | * **hypernova** - The universal component that takes care of turning your view into the HTML structure it needs to server-render. On the browser it bootstraps the server-rendered markup and runs it. 37 | * **hypernova-${client}** - This can be something like `hypernova-ruby` or `hypernova-node`. It is the client which gives your application the superpower of querying Hypernova and understanding how to fallback to client-rendering in case there is a failure. 38 | 39 | ## Get Started 40 | 41 | First you’ll need to install a few packages: the server, the browser component, and the client. For development purposes it is recommended to install either alongside the code you wish to server-render or in the same application. 42 | 43 | From here on out we’ll assume you’re using [`hypernova-ruby`](https://github.com/airbnb/hypernova-ruby) and `React` with [`hypernova-react`](https://github.com/airbnb/hypernova-react). 44 | 45 | ### Node 46 | 47 | ```sh 48 | npm install hypernova --save 49 | ``` 50 | 51 | This package contains both the server and the client. 52 | 53 | Next, lets configure the development server. To keep things simple we can put the configuration in your root folder, it can be named something like `hypernova.js`. 54 | 55 | ```js 56 | var hypernova = require('hypernova/server'); 57 | 58 | hypernova({ 59 | devMode: true, 60 | 61 | getComponent(name) { 62 | if (name === 'MyComponent.js') { 63 | return require('./app/assets/javascripts/MyComponent.js'); 64 | } 65 | return null; 66 | }, 67 | 68 | port: 3030, 69 | }); 70 | ``` 71 | 72 | Only the `getComponent` function is required for Hypernova. All other configuration options are optional. [Notes on `getComponent` can be found below](#getcomponent). 73 | 74 | We can run this server by starting it up with node. 75 | 76 | ```sh 77 | node hypernova.js 78 | ``` 79 | 80 | If all goes well you should see a message that says "Connected". If there is an issue, a stack trace should appear in `stderr`. 81 | 82 | ### Rails 83 | 84 | If your server code is written in a language other than Ruby, then you can build your own client for Hypernova. A [spec](docs/client-spec.md) exists and details on how clients should function as well as fall-back in case of failure. 85 | 86 | Add this line to your application’s Gemfile: 87 | 88 | ```ruby 89 | gem 'hypernova' 90 | ``` 91 | 92 | And then execute: 93 | 94 | $ bundle 95 | 96 | Or install it yourself as: 97 | 98 | $ gem install hypernova 99 | 100 | Now lets add support on the Rails side for Hypernova. First, we’ll need to create an initializer. 101 | 102 | `config/initializers/hypernova_initializer.rb` 103 | 104 | ```ruby 105 | Hypernova.configure do |config| 106 | config.host = "localhost" 107 | config.port = 3030 # The port where the node service is listening 108 | end 109 | ``` 110 | 111 | In your controller, you’ll need an `:around_filter` so you can opt into Hypernova rendering of view partials. 112 | 113 | ```ruby 114 | class SampleController < ApplicationController 115 | around_filter :hypernova_render_support 116 | end 117 | ``` 118 | 119 | And then in your view we `render_react_component`. 120 | 121 | ```ruby 122 | <%= render_react_component('MyComponent.js', :name => 'Hypernova The Renderer') %> 123 | ``` 124 | 125 | ### JavaScript 126 | 127 | Finally, lets set up `MyComponent.js` to be server-rendered. We will be using React to render. 128 | 129 | ```js 130 | const React = require('react'); 131 | const renderReact = require('hypernova-react').renderReact; 132 | 133 | function MyComponent(props) { 134 | return
Hello, {props.name}!
; 135 | } 136 | 137 | module.exports = renderReact('MyComponent.js', MyComponent); 138 | ``` 139 | 140 | Visit the page and you should see your React component has been server-rendered. If you’d like to confirm, you can view the source of the page and look for `data-hypernova-key`. If you see a `div` filled with HTML then your component was server-rendered, if the `div` is empty then there was a problem and your component was client-rendered as a fall-back strategy. 141 | 142 | If the `div` was empty, you can check `stderr` where you’re running the node service. 143 | 144 | ## Debugging 145 | 146 | The [developer plugin](https://github.com/airbnb/hypernova-ruby/blob/master/lib/hypernova/plugins/development_mode_plugin.rb) for [`hypernova-ruby`](https://github.com/airbnb/hypernova-ruby) is useful for debugging issues with Hypernova and why it falls back to client-rendering. It’ll display a warning plus a stack trace on the page whenever a component fails to render server-side. 147 | 148 | You can install the developer plugin in `examples/simple/config/environments/development.rb` 149 | 150 | ```ruby 151 | require 'hypernova' 152 | require 'hypernova/plugins/development_mode_plugin' 153 | 154 | Hypernova.add_plugin!(DevelopmentModePlugin.new) 155 | ``` 156 | 157 | You can also check the output of the server. The server outputs to `stdout` and `stderr` so if there is an error, check the process where you ran `node hypernova.js` and you should see the error. 158 | 159 | ## Deploying 160 | 161 | The recommended approach is running two separate servers, one that contains your server code and another that contains the Hypernova service. You’ll need to deploy the JavaScript code to the server that contains the Hypernova service as well. 162 | 163 | Depending on how you have `getComponent` configured, you might need to restart your Hypernova service on every deploy. If `getComponent` caches any code then a restart is paramount so that Hypernova receives the new changes. Caching is recommended because it helps speed up the service. 164 | 165 | ## FAQ 166 | 167 | > Isn’t sending an HTTP request slow? 168 | 169 | There isn’t a lot of overhead or latency, especially if you keep the servers in close proximity to each other. It’s as fast as compiling many ERB templates and gives you the benefit of unifying your view code. 170 | 171 | > Why not an in-memory JS VM? 172 | 173 | This is a valid option. If you’re looking for a siloed experience where the JS service is kept separate, then Hypernova is right for you. This approach also lends itself better to environments that don’t already have a JS VM available. 174 | 175 | > What if the server blows up? 176 | 177 | If something bad happens while Hypernova is attempting to server-render your components it’ll default to failure mode where your page will be client-rendered instead. While this is a comfortable safety net, the goal is to server-render every request. 178 | 179 | ## Pitfalls 180 | 181 | These are pitfalls of server-rendering JavaScript code and are not specific to Hypernova. 182 | 183 | * You’ll want to do any DOM-related manipulations in `componentDidMount`. `componentDidMount` runs 184 | on the browser but not the server, which means it’s safe to put DOM logic in there. 185 | Putting logic outside of the component, in the constructor, or in `componentWillMount` will 186 | cause the code to fail since the DOM isn’t present on the server. 187 | 188 | * It is recommended that you run your code in a VM sandbox so that requests get a fresh new 189 | JavaScript environment. In the event that you decide not to use a VM, you should be aware that 190 | singleton patterns and globals run the risk of leaking memory and/or leaking data 191 | between requests. If you use `createGetComponent` you’ll get VM by default. 192 | 193 | ## Clients 194 | 195 | See [clients.md](docs/clients.md) 196 | 197 | ## Browser 198 | 199 | The included browser package is a barebones helper which renders markup on the server and then loads it on the browser. 200 | 201 | List of compatible browser packages: 202 | 203 | * [`hypernova-react`](https://github.com/airbnb/hypernova-react) 204 | * [`hypernova-aphrodite`](https://github.com/airbnb/hypernova-aphrodite) 205 | * [`hypernova-styled-components`](https://github.com/viatsko/hypernova-styled-components) 206 | 207 | ## Server 208 | 209 | Starting up a Hypernova server 210 | 211 | ```js 212 | const hypernova = require('hypernova/server'); 213 | 214 | hypernova({ 215 | getComponent: require, 216 | }); 217 | ``` 218 | 219 | Options, and their defaults 220 | 221 | ```js 222 | { 223 | // the limit at which body parser will throw 224 | bodyParser: { 225 | limit: 1024 * 1000, 226 | }, 227 | // runs on a single process 228 | devMode: false, 229 | // how components will be retrieved, 230 | getComponent: undefined, 231 | // if not overridden, default will return the number of reported cpus - 1 232 | getCPUs: undefined, 233 | // the host the app will bind to 234 | host: '0.0.0.0', 235 | // configure the default winston logger 236 | logger: {}, 237 | // logger instance to use instead of the default winston logger 238 | loggerInstance: undefined, 239 | // the port the app will start on 240 | port: 8080, 241 | // default endpoint path 242 | endpoint: '/batch', 243 | // whether jobs in a batch are processed concurrently 244 | processJobsConcurrently: true, 245 | // arguments for server.listen, by default set to the configured [port, host] 246 | listenArgs: null, 247 | // default function to create an express app 248 | createApplication: () => express() 249 | } 250 | ``` 251 | 252 | #### `getComponent` 253 | 254 | This lets you provide your own implementation on how components are retrieved. 255 | 256 | The most common use-case would be to use a VM to keep each module sandboxed between requests. You can use `createGetComponent` from Hypernova to retrieve a `getComponent` function that does this. 257 | 258 | `createGetComponent` receives an Object whose keys are the component’s registered name and the value is the absolute path to the component. 259 | 260 | ```js 261 | const path = require('path'); 262 | 263 | hypernova({ 264 | getComponent: createGetComponent({ 265 | MyComponent: path.resolve(path.join('app', 'assets', 'javascripts', 'MyComponent.js')), 266 | }), 267 | }); 268 | ``` 269 | 270 | The simplest `getComponent` would be to use `require`. One drawback here is that your components would be cached between requests and thus could leak memory and/or data. Another drawback is that the files would have to exist relative to where this require is being used. 271 | 272 | ```js 273 | hypernova({ 274 | getComponent: require, 275 | }); 276 | ``` 277 | 278 | You can also fetch components asynchronously if you wish, and/or cache them. Just return a `Promise` from `getComponent`. 279 | 280 | ```js 281 | hypernova({ 282 | getComponent(name) { 283 | return promiseFetch('https://MyComponent'); 284 | }, 285 | }); 286 | ``` 287 | 288 | #### `getCPUs` 289 | 290 | This lets you specify the number of cores Hypernova will run workers on. Receives an argument containing the number of cores as reported by the OS. 291 | 292 | If this method is not overridden, or if a falsy value is passed, the default method will return the number of reported cores minus 1. 293 | 294 | #### `loggerInstance` 295 | This lets you provide your own implementation of a logger as long as it has a `log()` method. 296 | 297 | ```js 298 | const winston = require('winston'); 299 | const options = {}; 300 | 301 | hypernova({ 302 | loggerInstance: new winston.Logger({ 303 | transports: [ 304 | new winston.transports.Console(options), 305 | ], 306 | }), 307 | }); 308 | ``` 309 | 310 | #### `processJobsConcurrently` 311 | 312 | This determines whether jobs in a batch are processed concurrently or serially. Serial execution is preferable if you use a renderer that is CPU bound and your plugins do not perform IO in the per job hooks. 313 | 314 | #### `createApplication` 315 | This lets you provide your own function that creates an express app. 316 | You are able to add your own express stuff like more routes, middlewares, etc. 317 | Notice that you __must__ pass a function that returns an express app without calling the `listen` method! 318 | 319 | ```js 320 | const express = require('express'); 321 | const yourOwnAwesomeMiddleware = require('custom-middleware'); 322 | 323 | hypernova({ 324 | createApplication: function() { 325 | const app = express(); 326 | app.use(yourOwnAwesomeMiddleware); 327 | 328 | app.get('/health', function(req, res) { 329 | return res.status(200).send('OK'); 330 | }); 331 | 332 | // this is mandatory. 333 | return app; 334 | } 335 | ``` 336 | 337 | ## API 338 | 339 | ### Browser 340 | 341 | #### load 342 | 343 | ```typescript 344 | type DeserializedData = { [x: string]: any }; 345 | type ServerRenderedPair = { node: HTMLElement, data: DeserializedData }; 346 | 347 | function load(name: string): Array {} 348 | ``` 349 | 350 | Looks up the server-rendered DOM markup and its corresponding `script` JSON payload and returns it. 351 | 352 | #### serialize 353 | 354 | ```typescript 355 | type DeserializedData = { [x: string]: any }; 356 | 357 | function serialize(name: string, html: string, data: DeserializedData): string {} 358 | ``` 359 | 360 | Generates the markup that the browser will need to bootstrap your view on the browser. 361 | 362 | #### toScript 363 | 364 | ```typescript 365 | type DeserializedData = { [x: string]: any }; 366 | type Attributes = { [x: string]: string }; 367 | 368 | function toScript(attrs: Attributes, props: DeserializedData): string {} 369 | ``` 370 | 371 | An interface that allows you to create extra `script` tags for loading more data on the browser. 372 | 373 | #### fromScript 374 | 375 | ```typescript 376 | type DeserializedData = { [x: string]: any }; 377 | type Attributes = { [x: string]: string }; 378 | 379 | function fromScript(attrs: Attributes): DeserializedData {} 380 | ``` 381 | 382 | The inverse of `toScript`, this function runs on the browser and attempts to find and `JSON.parse` the contents of the server generated script. 383 | `attrs` is an object where the key will be a `data-key` to be placed on the element, and the value is the data attribute's value. 384 | 385 | The `serialize` function uses the attributes `DATA_KEY` and `DATA_ID` to generate the data markup. They can be used in the `fromScript` function to get the serialized data. 386 | 387 | ```typescript 388 | import { DATA_KEY, DATA_ID } from 'hypernova' 389 | 390 | fromScript({ 391 | [DATA_KEY]: key, 392 | [DATA_ID]: id, 393 | }); 394 | ``` 395 | 396 | ### Server 397 | 398 | #### [createGetComponent](src/createGetComponent.js) 399 | 400 | ```typescript 401 | type Files = { [key: string]: string }; 402 | type VMOptions = { cacheSize: number, environment?: () => any }; 403 | type GetComponent = (name: string) => any; 404 | 405 | function createGetComponent(files: Files, vmOptions: VMOptions): GetComponent {} 406 | ``` 407 | 408 | Creates a `getComponent` function which can then be passed into Hypernova so it knows how to retrieve your components. `createGetComponent` will create a VM so all your bundles can run independently from each other on each request so they don’t interfere with global state. Each component is also cached at startup in order to help speed up run time. The files Object key is the component’s name and its value is the absolute path to the component. 409 | 410 | #### [createVM](src/createVM.js) 411 | 412 | ```typescript 413 | type VMOptions = { cacheSize: number, environment?: () => any }; 414 | type Run = (name: string, code: string) => any; 415 | type VMContainer = { exportsCache: any, run: Run }; 416 | 417 | function createVM(options: VMOptions): VMContainer {} 418 | ``` 419 | 420 | Creates a VM using Node’s [`vm`](https://nodejs.org/api/vm.html) module. Calling `run` will run the provided code and return its `module.exports`. `exportsCache` is an instance of [`lru-cache`](https://github.com/isaacs/node-lru-cache). 421 | 422 | #### [getFiles](src/getFiles.js) 423 | 424 | ```typescript 425 | function getFiles(fullPathStr: string): Array<{name: string, path: string}> {} 426 | ``` 427 | 428 | A utility function that allows you to retrieve all JS files recursively given an absolute path. 429 | 430 | #### [Module](src/Module.js) 431 | 432 | `Module` is a class that mimics Node’s [`module`](https://github.com/nodejs/node/blob/master/lib/module.js) interface. It makes `require` relative to whatever directory it’s run against and makes sure that each JavaScript module runs in its own clean sandbox. 433 | 434 | #### [loadModules](src/loadModules.js) 435 | 436 | ```typescript 437 | function loadModules(require: any, files: Array): () => Module? {} 438 | ``` 439 | 440 | Loads all of the provided files into a `Module` that can be used as a parent `Module` inside a `VM`. This utility is useful when you need to pre-load a set of shims, shams, or JavaScript files that alter the runtime context. The `require` parameter is Node.js’ `require` function. 441 | -------------------------------------------------------------------------------- /docs/client-spec.md: -------------------------------------------------------------------------------- 1 | ## Client Spec 2 | 3 | 1. Call `getViewData(name, data)` for every view provided. 4 | - 1.1 Use the return value as the `data` field's value for the Jobs Object. 5 | 2. Call `prepareRequest(currentJobs, originalJobs)` as a reducer. 6 | - 2.1 The return value becomes the new Jobs Object. 7 | 3. Call `shouldSendRequest(jobs)` and pass in the Jobs object. 8 | - 3.1 If `false`. 9 | * 3.1.1 Create a Response Object. 10 | * 3.1.2 The `html` attribute is the fallback client-rendered output. 11 | * 3.1.3 The `error` attribute should be `null`. 12 | 4. Call `willSendRequest(jobs)`. 13 | - 4.1 Submit the HTTP Request as a POST. 14 | 5. If any error occurs up until this point create a Response Object. 15 | - 5.1 The `error` attribute should equal the Error that was thrown. 16 | - 5.2 The `html` attribute is the fallback client-rendered output. 17 | - 5.3 Call `onError(error, jobs)`. 18 | 6. When a response is received from the server: 19 | - 6.1 Iterate over the response, if the `error` field is not null then call `onError(error, job)` per job. 20 | - 6.2 Ensure that every job has an `html` field and that it is a string. If there is no HTML then use the fallback client-rendered output. 21 | 7. Call `onSuccess(response, jobs)`. 22 | 8. Call `afterResponse(currentResponse, originalResponse)` as a reducer. 23 | 9. If an error is encountered then call `onError(error, jobs)` and assert that the fallback HTML is provided. 24 | 25 | ## Client URL and Request Information 26 | 27 | #### Constructing the URL 28 | 29 | `HOST` is whatever you're running Hypernova on `localhost` usually works if you're testing locally. 30 | 31 | `PORT` is whatever is specified when you setup Hypernova. You can see an example [here in the README](https://github.com/airbnb/hypernova#node) and there's also [this working example](https://github.com/airbnb/hypernova/blob/master/examples/simple/hypernova.js#L13). 32 | 33 | The URL is `/batch` and that's just because that's what it was named. It's the [only route](https://github.com/airbnb/hypernova/blob/master/src/worker.js#L21) that is defined for express. 34 | 35 | So the full URL would be something like `http://localhost:3030/batch` 36 | 37 | #### Posting a batch of jobs 38 | 39 | The `POST` to `/batch` should look something like: 40 | 41 | ```js 42 | { 43 | "NameOfComponent": { 44 | "name": "NameOfComponent", 45 | "data": { 46 | "theseAreProps": true, 47 | "someOtherProps": ["one", "two", "three"] 48 | }, 49 | } 50 | } 51 | ``` 52 | 53 | If [`getComponent`](https://github.com/airbnb/hypernova#getcomponent) returns something for `"NameOfComponent"` then you should be good to go. 54 | 55 | ## Plugin Lifecycle API 56 | 57 | ```typescript 58 | function getViewData(viewName: string, data: any): any {} 59 | ``` 60 | 61 | Allows you to alter the data that a "view" will receive. 62 | 63 | ```typescript 64 | type Job = { name: string, data: any }; 65 | type Jobs = { [string]: Job }; 66 | function prepareRequest(currentJobs: Jobs, originalJobs: Jobs): Jobs {} 67 | ``` 68 | 69 | A reducer type function that is called when preparing the request that will be sent to Hypernova. This function receives the current running jobs Object and the original jobs Object. 70 | 71 | ```typescript 72 | function shouldSendRequest(jobs: Jobs): boolean {} 73 | ``` 74 | 75 | An `every` type function. If one returns `false` then the request is canceled. 76 | 77 | ```typescript 78 | function willSendRequest(jobs: Jobs): void {} 79 | ``` 80 | 81 | An event type function that is called prior to a request being sent. 82 | 83 | ```typescript 84 | type Job = { name: string, data: any }; 85 | type Response = { 86 | [string]: { 87 | error: ?Error, 88 | html: string, 89 | job: Job, 90 | }, 91 | }; 92 | function afterResponse(currentResponse: any, originalResponse: Response): any {} 93 | ``` 94 | 95 | A reducer type function which receives the current response and the original response from the Hypernova service. 96 | 97 | ```typescript 98 | type Job = { name: string, data: any }; 99 | type Jobs = { [string]: Job }; 100 | function onSuccess(response: any, jobs: Jobs): void {} 101 | ``` 102 | 103 | An event type function that is called whenever a request was successful. 104 | 105 | ```typescript 106 | type Job = { name: string, data: any }; 107 | type Jobs = { [string]: Job }; 108 | function onError(err: Error, jobs: Jobs): void {} 109 | ``` 110 | 111 | An event type function that is called whenever any error is encountered. 112 | -------------------------------------------------------------------------------- /docs/clients.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | The following are clients in the wild for connecting to Hypernova. 4 | 5 | * [Node.js](https://github.com/airbnb/hypernova-node) 6 | * [Rails](https://github.com/airbnb/hypernova-ruby) 7 | * [PHP](https://github.com/wayfair/hypernova-php) 8 | * [Pyramid](https://github.com/Yelp/pyramid-hypernova) 9 | -------------------------------------------------------------------------------- /docs/hypernova-how-it-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/docs/hypernova-how-it-works.png -------------------------------------------------------------------------------- /docs/markup.md: -------------------------------------------------------------------------------- 1 | # Markup 2 | 3 | ## Mustache Template 4 | 5 | ```mustache 6 |
{{html}}
7 | 8 | ``` 9 | -------------------------------------------------------------------------------- /examples/simple/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | !/log/.keep 17 | /tmp 18 | -------------------------------------------------------------------------------- /examples/simple/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '~> 2.3.0' 3 | 4 | 5 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 6 | gem 'rails', '4.2.7.1' 7 | # Use sqlite3 as the database for Active Record 8 | gem 'sqlite3' 9 | # Use SCSS for stylesheets 10 | gem 'sass-rails', '~> 5.0' 11 | # Use Uglifier as compressor for JavaScript assets 12 | gem 'uglifier', '>= 1.3.0' 13 | # Use CoffeeScript for .coffee assets and views 14 | gem 'coffee-rails', '~> 4.1.0' 15 | # See https://github.com/rails/execjs#readme for more supported runtimes 16 | # gem 'therubyracer', platforms: :ruby 17 | 18 | # Use jquery as the JavaScript library 19 | gem 'jquery-rails' 20 | # Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks 21 | gem 'turbolinks' 22 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 23 | gem 'jbuilder', '~> 2.0' 24 | # bundle exec rake doc:rails generates the API under doc/api. 25 | gem 'sdoc', '~> 0.4.0', group: :doc 26 | 27 | # Use ActiveModel has_secure_password 28 | # gem 'bcrypt', '~> 3.1.7' 29 | 30 | # Use Unicorn as the app server 31 | # gem 'unicorn' 32 | 33 | # Use Capistrano for deployment 34 | # gem 'capistrano-rails', group: :development 35 | 36 | gem "browserify-rails" 37 | gem "hypernova" 38 | 39 | group :development, :test do 40 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 41 | gem 'byebug' 42 | end 43 | 44 | group :development do 45 | # Access an IRB console on exception pages or by using <%= console %> in views 46 | gem 'web-console', '~> 2.0' 47 | 48 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 49 | gem 'spring' 50 | end 51 | -------------------------------------------------------------------------------- /examples/simple/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actionmailer (4.2.7.1) 5 | actionpack (= 4.2.7.1) 6 | actionview (= 4.2.7.1) 7 | activejob (= 4.2.7.1) 8 | mail (~> 2.5, >= 2.5.4) 9 | rails-dom-testing (~> 1.0, >= 1.0.5) 10 | actionpack (4.2.7.1) 11 | actionview (= 4.2.7.1) 12 | activesupport (= 4.2.7.1) 13 | rack (~> 1.6) 14 | rack-test (~> 0.6.2) 15 | rails-dom-testing (~> 1.0, >= 1.0.5) 16 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 17 | actionview (4.2.7.1) 18 | activesupport (= 4.2.7.1) 19 | builder (~> 3.1) 20 | erubis (~> 2.7.0) 21 | rails-dom-testing (~> 1.0, >= 1.0.5) 22 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 23 | activejob (4.2.7.1) 24 | activesupport (= 4.2.7.1) 25 | globalid (>= 0.3.0) 26 | activemodel (4.2.7.1) 27 | activesupport (= 4.2.7.1) 28 | builder (~> 3.1) 29 | activerecord (4.2.7.1) 30 | activemodel (= 4.2.7.1) 31 | activesupport (= 4.2.7.1) 32 | arel (~> 6.0) 33 | activesupport (4.2.7.1) 34 | i18n (~> 0.7) 35 | json (~> 1.7, >= 1.7.7) 36 | minitest (~> 5.1) 37 | thread_safe (~> 0.3, >= 0.3.4) 38 | tzinfo (~> 1.1) 39 | addressable (2.5.2) 40 | public_suffix (>= 2.0.2, < 4.0) 41 | arel (6.0.4) 42 | binding_of_caller (0.8.0) 43 | debug_inspector (>= 0.0.1) 44 | browserify-rails (4.3.0) 45 | addressable (>= 2.4.0) 46 | railties (>= 4.0.0) 47 | sprockets (>= 3.6.0) 48 | builder (3.2.3) 49 | byebug (10.0.2) 50 | coffee-rails (4.1.1) 51 | coffee-script (>= 2.2.0) 52 | railties (>= 4.0.0, < 5.1.x) 53 | coffee-script (2.4.1) 54 | coffee-script-source 55 | execjs 56 | coffee-script-source (1.12.2) 57 | concurrent-ruby (1.1.3) 58 | crass (1.0.4) 59 | debug_inspector (0.0.3) 60 | erubis (2.7.0) 61 | execjs (2.7.0) 62 | faraday (0.15.3) 63 | multipart-post (>= 1.2, < 3) 64 | ffi (1.9.25) 65 | globalid (0.4.1) 66 | activesupport (>= 4.2.0) 67 | hypernova (1.3.0) 68 | faraday (~> 0.8) 69 | i18n (0.9.5) 70 | concurrent-ruby (~> 1.0) 71 | jbuilder (2.8.0) 72 | activesupport (>= 4.2.0) 73 | multi_json (>= 1.2) 74 | jquery-rails (4.3.3) 75 | rails-dom-testing (>= 1, < 3) 76 | railties (>= 4.2.0) 77 | thor (>= 0.14, < 2.0) 78 | json (1.8.6) 79 | loofah (2.2.3) 80 | crass (~> 1.0.2) 81 | nokogiri (>= 1.5.9) 82 | mail (2.7.1) 83 | mini_mime (>= 0.1.1) 84 | mini_mime (1.0.1) 85 | mini_portile2 (2.3.0) 86 | minitest (5.11.3) 87 | multi_json (1.13.1) 88 | multipart-post (2.0.0) 89 | nokogiri (1.8.5) 90 | mini_portile2 (~> 2.3.0) 91 | public_suffix (3.0.3) 92 | rack (1.6.11) 93 | rack-test (0.6.3) 94 | rack (>= 1.0) 95 | rails (4.2.7.1) 96 | actionmailer (= 4.2.7.1) 97 | actionpack (= 4.2.7.1) 98 | actionview (= 4.2.7.1) 99 | activejob (= 4.2.7.1) 100 | activemodel (= 4.2.7.1) 101 | activerecord (= 4.2.7.1) 102 | activesupport (= 4.2.7.1) 103 | bundler (>= 1.3.0, < 2.0) 104 | railties (= 4.2.7.1) 105 | sprockets-rails 106 | rails-deprecated_sanitizer (1.0.3) 107 | activesupport (>= 4.2.0.alpha) 108 | rails-dom-testing (1.0.9) 109 | activesupport (>= 4.2.0, < 5.0) 110 | nokogiri (~> 1.6) 111 | rails-deprecated_sanitizer (>= 1.0.1) 112 | rails-html-sanitizer (1.0.4) 113 | loofah (~> 2.2, >= 2.2.2) 114 | railties (4.2.7.1) 115 | actionpack (= 4.2.7.1) 116 | activesupport (= 4.2.7.1) 117 | rake (>= 0.8.7) 118 | thor (>= 0.18.1, < 2.0) 119 | rake (12.3.1) 120 | rb-fsevent (0.10.3) 121 | rb-inotify (0.9.10) 122 | ffi (>= 0.5.0, < 2) 123 | rdoc (4.3.0) 124 | sass (3.7.2) 125 | sass-listen (~> 4.0.0) 126 | sass-listen (4.0.0) 127 | rb-fsevent (~> 0.9, >= 0.9.4) 128 | rb-inotify (~> 0.9, >= 0.9.7) 129 | sass-rails (5.0.7) 130 | railties (>= 4.0.0, < 6) 131 | sass (~> 3.1) 132 | sprockets (>= 2.8, < 4.0) 133 | sprockets-rails (>= 2.0, < 4.0) 134 | tilt (>= 1.1, < 3) 135 | sdoc (0.4.2) 136 | json (~> 1.7, >= 1.7.7) 137 | rdoc (~> 4.0) 138 | spring (2.0.2) 139 | activesupport (>= 4.2) 140 | sprockets (3.7.2) 141 | concurrent-ruby (~> 1.0) 142 | rack (> 1, < 3) 143 | sprockets-rails (3.2.1) 144 | actionpack (>= 4.0) 145 | activesupport (>= 4.0) 146 | sprockets (>= 3.0.0) 147 | sqlite3 (1.3.13) 148 | thor (0.20.3) 149 | thread_safe (0.3.6) 150 | tilt (2.0.8) 151 | turbolinks (5.2.0) 152 | turbolinks-source (~> 5.2) 153 | turbolinks-source (5.2.0) 154 | tzinfo (1.2.5) 155 | thread_safe (~> 0.1) 156 | uglifier (4.1.19) 157 | execjs (>= 0.3.0, < 3) 158 | web-console (2.3.0) 159 | activemodel (>= 4.0) 160 | binding_of_caller (>= 0.7.2) 161 | railties (>= 4.0) 162 | sprockets-rails (>= 2.0, < 4.0) 163 | 164 | PLATFORMS 165 | ruby 166 | 167 | DEPENDENCIES 168 | browserify-rails 169 | byebug 170 | coffee-rails (~> 4.1.0) 171 | hypernova 172 | jbuilder (~> 2.0) 173 | jquery-rails 174 | rails (= 4.2.7.1) 175 | sass-rails (~> 5.0) 176 | sdoc (~> 0.4.0) 177 | spring 178 | sqlite3 179 | turbolinks 180 | uglifier (>= 1.3.0) 181 | web-console (~> 2.0) 182 | 183 | RUBY VERSION 184 | ruby 2.3.8p459 185 | 186 | BUNDLED WITH 187 | 1.16.6 188 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | > How to run this example 2 | 3 | After you've git cloned the repository and are in the `examples/simple` directory you'll need to run a few commands. 4 | 5 | ```sh 6 | bundle install 7 | npm install 8 | 9 | node hypernova.js 10 | ``` 11 | 12 | ...and in a separate shell: 13 | 14 | ```sh 15 | bin/rails server 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/simple/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /examples/simple/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /examples/simple/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/app/assets/images/.keep -------------------------------------------------------------------------------- /examples/simple/app/assets/javascripts/MyComponent.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var renderReact = require('hypernova-react').renderReact; 3 | 4 | function MyComponent(props) { 5 | return React.createElement('div', { 6 | onClick() { 7 | alert('Click handlers work.'); 8 | }, 9 | }, 'Hello, ' + props.name + '!'); 10 | } 11 | 12 | module.exports = renderReact('MyComponent.js', MyComponent); 13 | -------------------------------------------------------------------------------- /examples/simple/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require turbolinks 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /examples/simple/app/assets/javascripts/welcome.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /examples/simple/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /examples/simple/app/assets/stylesheets/welcome.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the welcome controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /examples/simple/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /examples/simple/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /examples/simple/app/controllers/welcome_controller.rb: -------------------------------------------------------------------------------- 1 | class WelcomeController < ApplicationController 2 | around_filter :hypernova_render_support 3 | def index 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /examples/simple/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /examples/simple/app/helpers/welcome_helper.rb: -------------------------------------------------------------------------------- 1 | module WelcomeHelper 2 | end 3 | -------------------------------------------------------------------------------- /examples/simple/app/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/app/mailers/.keep -------------------------------------------------------------------------------- /examples/simple/app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/app/models/.keep -------------------------------------------------------------------------------- /examples/simple/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/app/models/concerns/.keep -------------------------------------------------------------------------------- /examples/simple/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Simple 5 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 6 | <%= csrf_meta_tags %> 7 | 8 | 9 | 10 | <%= yield %> 11 | 12 | 13 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 14 | 15 | -------------------------------------------------------------------------------- /examples/simple/app/views/welcome/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render_react_component('MyComponent.js', :name => 'Hypernova') %> 2 | -------------------------------------------------------------------------------- /examples/simple/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /examples/simple/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../../config/application', __FILE__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /examples/simple/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /examples/simple/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /examples/simple/bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | if (match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m)) 11 | Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq.join(Gem.path_separator) } 12 | gem 'spring', match[1] 13 | require 'spring/binstub' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /examples/simple/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /examples/simple/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Simple 10 | class Application < Rails::Application 11 | # Settings in config/environments/* take precedence over those specified here. 12 | # Application configuration should go into files in config/initializers 13 | # -- all .rb files in that directory are automatically loaded. 14 | 15 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 16 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 17 | # config.time_zone = 'Central Time (US & Canada)' 18 | 19 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 20 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 21 | # config.i18n.default_locale = :de 22 | 23 | # Do not swallow errors in after_commit/after_rollback callbacks. 24 | config.active_record.raise_in_transactional_callbacks = true 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /examples/simple/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /examples/simple/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /examples/simple/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /examples/simple/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require 'hypernova' 2 | require 'hypernova/plugins/development_mode_plugin' 3 | 4 | Rails.application.configure do 5 | # Settings specified here will take precedence over those in config/application.rb. 6 | 7 | # In the development environment your application's code is reloaded on 8 | # every request. This slows down response time but is perfect for development 9 | # since you don't have to restart the web server when you make code changes. 10 | config.cache_classes = false 11 | 12 | # Do not eager load code on boot. 13 | config.eager_load = false 14 | 15 | # Show full error reports and disable caching. 16 | config.consider_all_requests_local = true 17 | config.action_controller.perform_caching = false 18 | 19 | # Don't care if the mailer can't send. 20 | config.action_mailer.raise_delivery_errors = false 21 | 22 | # Print deprecation notices to the Rails logger. 23 | config.active_support.deprecation = :log 24 | 25 | # Raise an error on page load if there are pending migrations. 26 | config.active_record.migration_error = :page_load 27 | 28 | # Debug mode disables concatenation and preprocessing of assets. 29 | # This option may cause significant delays in view rendering with a large 30 | # number of complex assets. 31 | config.assets.debug = true 32 | 33 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 34 | # yet still be able to expire them through the digest params. 35 | config.assets.digest = true 36 | 37 | # Adds additional error checking when serving assets at runtime. 38 | # Checks for improperly declared sprockets dependencies. 39 | # Raises helpful error messages. 40 | config.assets.raise_runtime_errors = true 41 | 42 | # Raises error for missing translations 43 | # config.action_view.raise_on_missing_translations = true 44 | end 45 | 46 | Hypernova.add_plugin!(DevelopmentModePlugin.new) 47 | -------------------------------------------------------------------------------- /examples/simple/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like 20 | # NGINX, varnish or squid. 21 | # config.action_dispatch.rack_cache = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? 26 | 27 | # Compress JavaScripts and CSS. 28 | config.assets.js_compressor = :uglifier 29 | # config.assets.css_compressor = :sass 30 | 31 | # Do not fallback to assets pipeline if a precompiled asset is missed. 32 | config.assets.compile = false 33 | 34 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 35 | # yet still be able to expire them through the digest params. 36 | config.assets.digest = true 37 | 38 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 39 | 40 | # Specifies the header that your server uses for sending files. 41 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 42 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 43 | 44 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 45 | # config.force_ssl = true 46 | 47 | # Use the lowest log level to ensure availability of diagnostic information 48 | # when problems arise. 49 | config.log_level = :debug 50 | 51 | # Prepend all log lines with the following tags. 52 | # config.log_tags = [ :subdomain, :uuid ] 53 | 54 | # Use a different logger for distributed setups. 55 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 56 | 57 | # Use a different cache store in production. 58 | # config.cache_store = :mem_cache_store 59 | 60 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 61 | # config.action_controller.asset_host = 'http://assets.example.com' 62 | 63 | # Ignore bad email addresses and do not raise email delivery errors. 64 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 65 | # config.action_mailer.raise_delivery_errors = false 66 | 67 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 68 | # the I18n.default_locale when a translation cannot be found). 69 | config.i18n.fallbacks = true 70 | 71 | # Send deprecation notices to registered listeners. 72 | config.active_support.deprecation = :notify 73 | 74 | # Use default logging formatter so that PID and timestamp are not suppressed. 75 | config.log_formatter = ::Logger::Formatter.new 76 | 77 | # Do not dump schema after migrations. 78 | config.active_record.dump_schema_after_migration = false 79 | end 80 | -------------------------------------------------------------------------------- /examples/simple/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/hypernova.rb: -------------------------------------------------------------------------------- 1 | require 'hypernova' 2 | 3 | Hypernova.configure do |config| 4 | config.host = 'localhost' 5 | config.port = 3030 6 | end 7 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_simple_session' 4 | -------------------------------------------------------------------------------- /examples/simple/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /examples/simple/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /examples/simple/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root 'welcome#index' 3 | 4 | # The priority is based upon order of creation: first created -> highest priority. 5 | # See how all your routes lay out with "rake routes". 6 | 7 | # You can have the root of your site routed with "root" 8 | # root 'welcome#index' 9 | 10 | # Example of regular route: 11 | # get 'products/:id' => 'catalog#view' 12 | 13 | # Example of named route that can be invoked with purchase_url(id: product.id) 14 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase 15 | 16 | # Example resource route (maps HTTP verbs to controller actions automatically): 17 | # resources :products 18 | 19 | # Example resource route with options: 20 | # resources :products do 21 | # member do 22 | # get 'short' 23 | # post 'toggle' 24 | # end 25 | # 26 | # collection do 27 | # get 'sold' 28 | # end 29 | # end 30 | 31 | # Example resource route with sub-resources: 32 | # resources :products do 33 | # resources :comments, :sales 34 | # resource :seller 35 | # end 36 | 37 | # Example resource route with more complex sub-resources: 38 | # resources :products do 39 | # resources :comments 40 | # resources :sales do 41 | # get 'recent', on: :collection 42 | # end 43 | # end 44 | 45 | # Example resource route with concerns: 46 | # concern :toggleable do 47 | # post 'toggle' 48 | # end 49 | # resources :posts, concerns: :toggleable 50 | # resources :photos, concerns: :toggleable 51 | 52 | # Example resource route within a namespace: 53 | # namespace :admin do 54 | # # Directs /admin/products/* to Admin::ProductsController 55 | # # (app/controllers/admin/products_controller.rb) 56 | # resources :products 57 | # end 58 | end 59 | -------------------------------------------------------------------------------- /examples/simple/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 31f18cace0831dd8a4222653a11e77f4213d114be2dabf0f6ce52be07ad29d76b7c71b1a953fbb17fee466bc6f68e0d92ae6d6aa0340231b81aa3b7a9acc8b74 15 | 16 | test: 17 | secret_key_base: e64e0b20f4f6b74cd8b27e6efa565b8b0df8ad99ae1227c60410de67d6c8bc42fb790afe4503ee73d909af1ab9d58c4ecad50bc902f6d834f36df87f17890e04 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /examples/simple/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /examples/simple/hypernova.js: -------------------------------------------------------------------------------- 1 | const hypernova = require('hypernova/server'); 2 | 3 | hypernova({ 4 | devMode: true, 5 | 6 | getComponent(name) { 7 | if (name === 'MyComponent.js') { 8 | return require('./app/assets/javascripts/MyComponent.js'); 9 | } 10 | return null; 11 | }, 12 | 13 | port: 3030, 14 | }); 15 | -------------------------------------------------------------------------------- /examples/simple/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/log/.keep -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypernova-simple-example", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "A sample Rails application that uses Hypernova to server render.", 6 | "main": "hypernova.js", 7 | "dependencies": { 8 | "hypernova": "^1.0.0", 9 | "hypernova-react": "^1.0.0", 10 | "react": "^15.0.1", 11 | "react-dom": "^15.0.1" 12 | }, 13 | "devDependencies": { 14 | "browserify": "^13.0.0", 15 | "browserify-incremental": "^3.1.1" 16 | }, 17 | "author": "Josh Perez ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /examples/simple/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/simple/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/simple/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /examples/simple/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/public/favicon.ico -------------------------------------------------------------------------------- /examples/simple/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /examples/simple/test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/controllers/.keep -------------------------------------------------------------------------------- /examples/simple/test/controllers/welcome_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class WelcomeControllerTest < ActionController::TestCase 4 | test "should get index" do 5 | get :index 6 | assert_response :success 7 | end 8 | 9 | end 10 | -------------------------------------------------------------------------------- /examples/simple/test/fixtures/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/fixtures/.keep -------------------------------------------------------------------------------- /examples/simple/test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/helpers/.keep -------------------------------------------------------------------------------- /examples/simple/test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/integration/.keep -------------------------------------------------------------------------------- /examples/simple/test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/mailers/.keep -------------------------------------------------------------------------------- /examples/simple/test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/test/models/.keep -------------------------------------------------------------------------------- /examples/simple/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require File.expand_path('../../config/environment', __FILE__) 3 | require 'rails/test_help' 4 | 5 | class ActiveSupport::TestCase 6 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 7 | fixtures :all 8 | 9 | # Add more helper methods to be used by all tests here... 10 | end 11 | -------------------------------------------------------------------------------- /examples/simple/vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/vendor/assets/javascripts/.keep -------------------------------------------------------------------------------- /examples/simple/vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/hypernova/125c346022084e2d820f9da490b4468cba60d25e/examples/simple/vendor/assets/stylesheets/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypernova", 3 | "version": "2.5.0", 4 | "description": "A service for server-side rendering your JavaScript views", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "prepublishOnly": "safe-publish-latest && npm run build", 8 | "prepublish": "not-in-publish || npm run prepublishOnly", 9 | "clean": "rimraf lib", 10 | "prebuild": "npm run clean", 11 | "build": "babel src -d lib", 12 | "prelint": "npm run build", 13 | "lint": "eslint src test", 14 | "pretest": "npm run --silent lint", 15 | "test": "npm run coverage", 16 | "pretests-only": "npm run build", 17 | "tests-only": "npm run test:quick", 18 | "posttest": "aud --production", 19 | "precoverage": "npm run build", 20 | "coverage": "babel-node node_modules/.bin/istanbul cover --report html node_modules/.bin/_mocha -- -R tap test/init.js test/*-test.js", 21 | "postcoverage": "npm run cover:check", 22 | "cover:check": "istanbul check-coverage && echo code coverage thresholds met, achievement unlocked!", 23 | "test:quick": "babel-node node_modules/.bin/_mocha -R tap test/init.js test/*-test.js" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git@github.com:airbnb/hypernova.git" 28 | }, 29 | "keywords": [ 30 | "react", 31 | "server", 32 | "render", 33 | "isomorphic", 34 | "universal", 35 | "express" 36 | ], 37 | "author": "Josh Perez ", 38 | "contributors": [ 39 | "Leland Richardson ", 40 | "Jordan Harband ", 41 | "Gary Borton ", 42 | "Stephen Bush ", 43 | "Ian Myers ", 44 | "Jake Teton-Landis " 45 | ], 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/airbnb/hypernova/issues" 49 | }, 50 | "homepage": "https://github.com/airbnb/hypernova", 51 | "devDependencies": { 52 | "aud": "^2.0.0", 53 | "babel-cli": "^6.26.0", 54 | "babel-plugin-add-module-exports": "^0.2.1", 55 | "babel-plugin-transform-replace-object-assign": "^1.0.0", 56 | "babel-preset-airbnb": "^2.5.3", 57 | "chai": "^4.3.6", 58 | "cheerio": "=1.0.0-rc.3", 59 | "eslint": "^8.14.0", 60 | "eslint-config-airbnb-base": "^15.0.0", 61 | "eslint-plugin-import": "^2.26.0", 62 | "in-publish": "^2.0.1", 63 | "mocha": "^3.5.3", 64 | "mocha-wrap": "^2.1.2", 65 | "nyc": "^10.3.2", 66 | "rimraf": "^2.6.3", 67 | "safe-publish-latest": "^2.0.0", 68 | "sinon": "^3.3.0", 69 | "sinon-sandbox": "^1.0.2" 70 | }, 71 | "dependencies": { 72 | "airbnb-js-shims": "^2 || ^3", 73 | "bluebird": "^3.7.2", 74 | "body-parser": "^1.20.0", 75 | "express": "^4.18.0", 76 | "glob": "^7.2.0", 77 | "has": "^1.0.3", 78 | "lru-cache": "^4.1.5", 79 | "object.assign": "^4.1.2", 80 | "winston": "^2.4.5" 81 | }, 82 | "engines": { 83 | "node": ">= 0.10" 84 | }, 85 | "greenkeeper": { 86 | "ignore": [ 87 | "mocha", 88 | "sinon" 89 | ] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/server.js'); 2 | -------------------------------------------------------------------------------- /src/Module.js: -------------------------------------------------------------------------------- 1 | import NativeModule from 'module'; 2 | import has from 'has'; 3 | import path from 'path'; 4 | import { ok } from 'assert'; 5 | import { runInNewContext } from 'vm'; 6 | 7 | const NativeModules = process.binding('natives'); 8 | 9 | // This means that you won't be able to affect VM extensions by mutating require.extensions 10 | // this is cool since we can now have different extensions for VM than for where your program is 11 | // running. 12 | // If you want to add an extension then you can use addExtension defined and exported below. 13 | const moduleExtensions = { ...NativeModule._extensions }; 14 | 15 | function isNativeModule(id) { 16 | return has(NativeModules, id); 17 | } 18 | 19 | // Creates a sandbox so we don't share globals across different runs. 20 | function createContext() { 21 | const sandbox = { 22 | Buffer, 23 | clearImmediate, 24 | clearInterval, 25 | clearTimeout, 26 | setImmediate, 27 | setInterval, 28 | setTimeout, 29 | console, 30 | process, 31 | }; 32 | sandbox.global = sandbox; 33 | return sandbox; 34 | } 35 | 36 | // This class should satisfy the Module interface that NodeJS defines in their native module.js 37 | // implementation. 38 | class Module { 39 | constructor(id, parent) { 40 | const cache = parent ? parent.cache : null; 41 | this.id = id; 42 | this.exports = {}; 43 | this.cache = cache || {}; 44 | this.parent = parent; 45 | this.filename = null; 46 | this.loaded = false; 47 | this.context = parent ? parent.context : createContext(); 48 | } 49 | 50 | load(filename) { 51 | ok(!this.loaded); 52 | this.filename = filename; 53 | this.paths = NativeModule._nodeModulePaths(path.dirname(filename)); 54 | } 55 | 56 | run(filename) { 57 | const ext = path.extname(filename); 58 | const extension = moduleExtensions[ext] ? ext : '.js'; 59 | moduleExtensions[extension](this, filename); 60 | this.loaded = true; 61 | } 62 | 63 | require(filePath) { 64 | ok(typeof filePath === 'string', 'path must be a string'); 65 | return Module.loadFile(filePath, this); 66 | } 67 | 68 | _compile(content, filename) { 69 | const self = this; 70 | 71 | function require(filePath) { 72 | return self.require(filePath); 73 | } 74 | require.resolve = (request) => NativeModule._resolveFilename(request, this); 75 | require.main = process.mainModule; 76 | require.extensions = moduleExtensions; 77 | require.cache = this.cache; 78 | 79 | const dirname = path.dirname(filename); 80 | 81 | // create wrapper function 82 | const wrapper = NativeModule.wrap(content); 83 | 84 | const options = { 85 | filename, 86 | displayErrors: true, 87 | }; 88 | 89 | const compiledWrapper = runInNewContext(wrapper, this.context, options); 90 | return compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); 91 | } 92 | 93 | static load(id, filename = id) { 94 | const module = new Module(id); 95 | module.load(filename); 96 | module.run(filename); 97 | return module; 98 | } 99 | 100 | static loadFile(file, parent) { 101 | const filename = NativeModule._resolveFilename(file, parent); 102 | 103 | if (parent) { 104 | const cachedModule = parent.cache[filename]; 105 | if (cachedModule) return cachedModule.exports; 106 | } 107 | 108 | if (isNativeModule(filename)) { 109 | // eslint-disable-next-line global-require, import/no-dynamic-require 110 | return require(filename); 111 | } 112 | 113 | const module = new Module(filename, parent); 114 | 115 | module.cache[filename] = module; 116 | 117 | let hadException = true; 118 | 119 | try { 120 | module.load(filename); 121 | module.run(filename); 122 | hadException = false; 123 | } finally { 124 | if (hadException) { 125 | delete module.cache[filename]; 126 | } 127 | } 128 | 129 | return module.exports; 130 | } 131 | 132 | static addExtension(ext, f) { 133 | moduleExtensions[ext] = f; 134 | } 135 | } 136 | 137 | export default Module; 138 | -------------------------------------------------------------------------------- /src/coordinator.js: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster'; 2 | import os from 'os'; 3 | 4 | import './environment'; 5 | import logger from './utils/logger'; 6 | import { raceTo } from './utils/lifecycle'; 7 | 8 | export function getDefaultCPUs(realCount) { 9 | if (!Number.isInteger(realCount) || realCount <= 0) { 10 | throw new TypeError('getDefaultCPUs must accept a positive integer'); 11 | } 12 | 13 | return realCount - 1 || 1; 14 | } 15 | 16 | export function getWorkerCount(getCPUs = getDefaultCPUs) { 17 | const realCount = os.cpus().length; 18 | 19 | if (typeof getCPUs !== 'function') { 20 | throw new TypeError('getCPUs must be a function'); 21 | } 22 | 23 | const requested = getCPUs(realCount); 24 | 25 | if (!Number.isInteger(requested) || requested <= 0) { 26 | throw new TypeError('getCPUs must return a positive integer'); 27 | } 28 | 29 | return requested; 30 | } 31 | 32 | function close() { 33 | return Promise.all(Object.values(cluster.workers).map((worker) => { 34 | const promise = new Promise((resolve, reject) => { 35 | worker.once('disconnect', resolve); 36 | worker.once('exit', (code) => { 37 | if (code !== 0) reject(); 38 | }); 39 | }); 40 | worker.send('kill'); 41 | return promise; 42 | })); 43 | } 44 | 45 | function kill(signal) { 46 | const liveWorkers = Object.values(cluster.workers).filter((worker) => !worker.isDead()); 47 | 48 | if (liveWorkers.length > 0) { 49 | logger.info(`Coordinator killing ${liveWorkers.length} live workers with ${signal}`); 50 | 51 | return Promise.all(liveWorkers.map((worker) => { 52 | const promise = new Promise((resolve) => { 53 | worker.once('exit', () => resolve()); 54 | }); 55 | 56 | worker.process.kill(signal); 57 | return promise; 58 | })); 59 | } 60 | 61 | return Promise.resolve(); 62 | } 63 | 64 | function killSequence(signal) { 65 | return () => raceTo(kill(signal), 2000, `Killing workers with ${signal} took too long`); 66 | } 67 | 68 | function shutdown() { 69 | return raceTo(close(), 5000, 'Closing the coordinator took too long.') 70 | .then(killSequence('SIGTERM'), killSequence('SIGTERM')) 71 | .then(killSequence('SIGKILL'), killSequence('SIGKILL')); 72 | } 73 | 74 | function workersReady(workerCount) { 75 | const workers = Object.values(cluster.workers); 76 | 77 | return workers.length === workerCount && workers.every((worker) => worker.isReady); 78 | } 79 | 80 | export default (getCPUs) => { 81 | const workerCount = getWorkerCount(getCPUs); 82 | let closing = false; 83 | 84 | function onWorkerMessage(msg) { 85 | if (msg.ready) { 86 | cluster.workers[msg.workerId].isReady = true; 87 | } 88 | 89 | if (workersReady(workerCount)) { 90 | Object.values(cluster.workers).forEach((worker) => worker.send('healthy')); 91 | } 92 | } 93 | 94 | cluster.on('online', (worker) => logger.info(`Worker #${worker.id} is now online`)); 95 | 96 | cluster.on('listening', (worker, address) => { 97 | logger.info(`Worker #${worker.id} is now connected to ${address.address}:${address.port}`); 98 | }); 99 | 100 | cluster.on('disconnect', (worker) => { 101 | logger.info(`Worker #${worker.id} has disconnected`); 102 | }); 103 | 104 | cluster.on('exit', (worker, code, signal) => { 105 | if (worker.exitedAfterDisconnect === true || code === 0) { 106 | logger.info(`Worker #${worker.id} shutting down.`); 107 | } else if (closing) { 108 | logger.error( 109 | `Worker #${worker.id} died with code ${signal || code} during close. Not restarting.`, 110 | ); 111 | } else { 112 | logger.error(`Worker #${worker.id} died with code ${signal || code}. Restarting worker.`); 113 | const newWorker = cluster.fork(); 114 | newWorker.on('message', onWorkerMessage); 115 | } 116 | }); 117 | 118 | process.on('SIGTERM', () => { 119 | logger.info('Hypernova got SIGTERM. Going down.'); 120 | closing = true; 121 | shutdown().then(() => process.exit(0), () => process.exit(1)); 122 | }); 123 | 124 | process.on('SIGINT', () => { 125 | closing = true; 126 | shutdown().then(() => process.exit(0), () => process.exit(1)); 127 | }); 128 | 129 | Array.from({ length: workerCount }, () => cluster.fork()); 130 | 131 | Object.values(cluster.workers).forEach((worker) => worker.on('message', onWorkerMessage)); 132 | }; 133 | -------------------------------------------------------------------------------- /src/createGetComponent.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import has from 'has'; 3 | 4 | import createVM from './createVM'; 5 | 6 | // This function takes in an Object of files and an Object that configures the VM. It will return 7 | // a function that can be used as `getComponent` for Hypernova. 8 | // The file's object structure is [componentName]: 'AbsolutePath.js' 9 | export default (files, vmOptions) => { 10 | const fileEntries = Object.entries(files); 11 | 12 | const vm = createVM({ 13 | cacheSize: fileEntries.length, 14 | ...vmOptions, 15 | }); 16 | 17 | const resolvedFiles = fileEntries.reduce((components, [fileName, filePath]) => { 18 | const code = fs.readFileSync(filePath, 'utf-8'); 19 | 20 | try { 21 | // Load the bundle on startup so we can cache its exports. 22 | vm.run(filePath, code); 23 | 24 | // Cache the code as well as the path to it. 25 | components[fileName] = { // eslint-disable-line no-param-reassign 26 | filePath, 27 | code, 28 | }; 29 | } catch (err) { 30 | // If loading the component failed then we'll skip it. 31 | // istanbul ignore next 32 | console.error(err.stack); 33 | } 34 | 35 | return components; 36 | }, {}); 37 | 38 | return (name) => { 39 | if (has(resolvedFiles, name)) { 40 | const { filePath, code } = resolvedFiles[name]; 41 | return vm.run(filePath, code); 42 | } 43 | 44 | // The requested package was not found. 45 | return null; 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/createVM.js: -------------------------------------------------------------------------------- 1 | import lruCache from 'lru-cache'; 2 | import crypto from 'crypto'; 3 | import Module from './Module'; 4 | 5 | function defaultGetKey(name, code) { 6 | const hash = crypto.createHash('sha1').update(code).digest('hex'); 7 | return `${name}::${hash}`; 8 | } 9 | 10 | export default (options = {}) => { 11 | // This is to cache the entry point of all bundles which makes running on a vm blazing fast. 12 | // Everyone gets their own sandbox to play with and nothing is leaked between requests. 13 | // We're caching with `code` as the key to ensure that if the code changes we break the cache. 14 | const exportsCache = lruCache({ 15 | max: options.cacheSize, 16 | }); 17 | 18 | const getKey = options.getKey || defaultGetKey; 19 | 20 | return { 21 | exportsCache, 22 | 23 | run(name, code) { 24 | const key = getKey(name, code); 25 | 26 | if (exportsCache.has(key)) return exportsCache.get(key); 27 | 28 | const environment = options.environment && options.environment(name); 29 | 30 | const module = new Module(name, environment); 31 | module.load(name); 32 | module._compile(code, name); 33 | 34 | exportsCache.set(key, module.exports); 35 | 36 | return module.exports; 37 | }, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint func-names:0 no-extra-parens:0 */ 2 | import 'airbnb-js-shims'; 3 | import Promise from 'bluebird'; 4 | 5 | const es6methods = ['then', 'catch', 'constructor']; 6 | const es6StaticMethods = ['all', 'race', 'resolve', 'reject', 'cast']; 7 | 8 | function isNotMethod(name) { 9 | return !(es6methods.includes(name) || es6StaticMethods.includes(name) || name.charAt(0) === '_'); 10 | } 11 | 12 | function del(obj) { 13 | /* eslint no-param-reassign: 0 */ 14 | return (key) => { delete obj[key]; }; 15 | } 16 | 17 | function toFastProperties(obj) { 18 | (function () {}).prototype = obj; 19 | } 20 | 21 | Object.keys(Promise.prototype).filter(isNotMethod).forEach(del(Promise.prototype)); 22 | Object.keys(Promise).filter(isNotMethod).forEach(del(Promise)); 23 | toFastProperties(Promise); 24 | toFastProperties(Promise.prototype); 25 | 26 | global.Promise = Promise; 27 | -------------------------------------------------------------------------------- /src/getFiles.js: -------------------------------------------------------------------------------- 1 | import glob from 'glob'; 2 | import path from 'path'; 3 | 4 | export default function getFiles(fullPathStr) { 5 | return glob.sync(path.join(fullPathStr, '**', '*.js')).map((file) => { 6 | const name = path.relative(fullPathStr, file); 7 | return { 8 | name, 9 | path: file, 10 | }; 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* globals document */ 2 | 3 | const LEFT = ''; 5 | 6 | const ENCODE = [ 7 | ['&', '&'], 8 | ['>', '>'], 9 | ]; 10 | 11 | const DATA_KEY = 'hypernova-key'; 12 | const DATA_ID = 'hypernova-id'; 13 | 14 | // https://gist.github.com/jed/982883 15 | function uuid() { 16 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace( 17 | /[018]/g, 18 | (x) => (x ^ Math.random() * 16 >> x / 4).toString(16), // eslint-disable-line no-mixed-operators, no-bitwise, max-len 19 | ); 20 | } 21 | 22 | function encode(obj) { 23 | return ENCODE.reduce((str, coding) => { 24 | const [encodeChar, htmlEntity] = coding; 25 | return str.replace(new RegExp(encodeChar, 'g'), htmlEntity); 26 | }, JSON.stringify(obj)); 27 | } 28 | 29 | function decode(res) { 30 | const jsonPayload = ENCODE.reduceRight((str, coding) => { 31 | const [encodeChar, htmlEntity] = coding; 32 | return str.replace(new RegExp(htmlEntity, 'g'), encodeChar); 33 | }, res); 34 | 35 | return JSON.parse(jsonPayload); 36 | } 37 | 38 | function makeValidDataAttribute(attr, value) { 39 | const encodedAttr = attr.toLowerCase().replace(/[^0-9a-z_-]/g, ''); 40 | const encodedValue = value.replace(/&/g, '&').replace(/"/g, '"'); 41 | return `data-${encodedAttr}="${encodedValue}"`; 42 | } 43 | 44 | function toScript(attrs, data) { 45 | const dataAttributes = Object.keys(attrs).map((name) => makeValidDataAttribute(name, attrs[name])); 46 | return ``; // eslint-disable-line max-len 47 | } 48 | 49 | function fromScript(attrs) { 50 | const selectors = Object.keys(attrs) 51 | .map((name) => `[${makeValidDataAttribute(name, attrs[name])}]`) 52 | .join(''); 53 | const node = document.querySelector(`script${selectors}`); 54 | if (!node) return null; 55 | const jsonPayload = node.innerHTML; 56 | 57 | return decode(jsonPayload.slice(LEFT.length, jsonPayload.length - RIGHT.length)); 58 | } 59 | 60 | function serialize(name, html, data) { 61 | const key = name.replace(/\W/g, ''); 62 | const id = uuid(); 63 | const markup = `
${html}
`; 64 | const script = toScript({ 65 | [DATA_KEY]: key, 66 | [DATA_ID]: id, 67 | }, data); 68 | return `${markup}\n${script}`; 69 | } 70 | 71 | function load(name) { 72 | const key = name.replace(/\W/g, ''); 73 | const nodes = document.querySelectorAll(`div[data-${DATA_KEY}="${key}"]`); 74 | 75 | return Array.prototype.map.call(nodes, (node) => { 76 | const id = node.getAttribute(`data-${DATA_ID}`); 77 | const data = fromScript({ 78 | [DATA_KEY]: key, 79 | [DATA_ID]: id, 80 | }); 81 | return { node, data }; 82 | }); 83 | } 84 | 85 | export default function hypernova(runner) { 86 | return typeof window === 'undefined' 87 | ? runner.server() 88 | : runner.client(); 89 | } 90 | 91 | hypernova.toScript = toScript; 92 | hypernova.fromScript = fromScript; 93 | hypernova.serialize = serialize; 94 | hypernova.load = load; 95 | hypernova.DATA_KEY = DATA_KEY; 96 | hypernova.DATA_ID = DATA_ID; 97 | -------------------------------------------------------------------------------- /src/loadModules.js: -------------------------------------------------------------------------------- 1 | import Module from './Module'; 2 | 3 | function load(file, parent) { 4 | if (!file) return parent; 5 | 6 | const module = new Module(file, parent); 7 | module.load(file); 8 | module.run(file); 9 | return module; 10 | } 11 | 12 | function resolve(require, name) { 13 | try { 14 | return require.resolve(name); 15 | } catch (e) { 16 | if (e.code === 'MODULE_NOT_FOUND') return null; 17 | throw e; 18 | } 19 | } 20 | 21 | export default function loadModules(require, files) { 22 | return () => files.reduce((module, file) => load(resolve(require, file), module), null); 23 | } 24 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster'; 2 | import express from 'express'; 3 | 4 | import './environment'; 5 | import Module from './Module'; 6 | import coordinator from './coordinator'; 7 | import createGetComponent from './createGetComponent'; 8 | import getFiles from './getFiles'; 9 | import loadModules from './loadModules'; 10 | import logger from './utils/logger'; 11 | import createVM from './createVM'; 12 | import worker from './worker'; 13 | import { raceTo } from './utils/lifecycle'; 14 | 15 | function createApplication() { 16 | return express(); 17 | } 18 | 19 | const defaultConfig = { 20 | bodyParser: { 21 | limit: 1024 * 1000, 22 | }, 23 | devMode: false, 24 | endpoint: '/batch', 25 | files: [], 26 | logger: {}, 27 | plugins: [], 28 | port: 8080, 29 | host: '0.0.0.0', 30 | processJobsConcurrent: true, 31 | listenArgs: null, 32 | createApplication, 33 | }; 34 | 35 | export default function hypernova(userConfig, onServer) { 36 | const config = { ...defaultConfig, ...userConfig }; 37 | 38 | if (typeof config.getComponent !== 'function') { 39 | throw new TypeError('Hypernova requires a `getComponent` property and it must be a function'); 40 | } 41 | 42 | if (!config.listenArgs) { 43 | config.listenArgs = [config.port, config.host]; 44 | } 45 | 46 | logger.init(config.logger, config.loggerInstance); 47 | 48 | if (typeof config.createApplication !== 'function') { 49 | throw new TypeError('Hypernova requires a `createApplication` property which must be a function that returns an express instance'); 50 | } 51 | 52 | const app = config.createApplication(); 53 | 54 | if ( 55 | typeof app !== 'function' 56 | || typeof app.use !== 'function' 57 | || typeof app.post !== 'function' 58 | || typeof app.listen !== 'function' 59 | ) { 60 | throw new TypeError( 61 | '`createApplication` must return a valid express instance with `use`, `post`, and `listen` methods', 62 | ); 63 | } 64 | 65 | if (config.devMode) { 66 | worker(app, config, onServer); 67 | } else if (cluster.isMaster) { 68 | coordinator(config.getCPUs); 69 | } else { 70 | worker(app, config, onServer, cluster.worker.id); 71 | } 72 | 73 | return app; 74 | } 75 | 76 | // I'm "exporting" them here because I want to export these but still have a default export. 77 | // And I want it to work on CJS. 78 | // I want my cake and to eat it all. 79 | hypernova.Module = Module; 80 | hypernova.createApplication = createApplication; 81 | hypernova.createGetComponent = createGetComponent; 82 | hypernova.createVM = createVM; 83 | hypernova.getFiles = getFiles; 84 | hypernova.loadModules = loadModules; 85 | hypernova.worker = worker; 86 | hypernova.logger = logger; 87 | hypernova.defaultConfig = defaultConfig; 88 | hypernova.raceTo = raceTo; 89 | -------------------------------------------------------------------------------- /src/utils/BatchManager.js: -------------------------------------------------------------------------------- 1 | const noHTMLError = new TypeError( 2 | 'HTML was not returned to Hypernova, this is most likely an error within your application. Check your logs for any uncaught errors and/or rejections.', 3 | ); 4 | noHTMLError.stack = null; 5 | 6 | function errorToSerializable(error) { 7 | // istanbul ignore next 8 | if (error === undefined) throw new TypeError('No error was passed'); 9 | 10 | // make sure it is an object that is Error-like so we can serialize it properly 11 | // if it's not an actual error then we won't create an Error so that there is no stack trace 12 | // because no stack trace is better than a stack trace that is generated here. 13 | const err = ( 14 | Object.prototype.toString.call(error) === '[object Error]' 15 | && typeof error.stack === 'string' 16 | ) ? error : { name: 'Error', type: 'Error', message: error, stack: '' }; 17 | 18 | return { 19 | type: err.type, 20 | name: err.name, 21 | message: err.message, 22 | stack: err.stack.split('\n '), 23 | }; 24 | } 25 | 26 | function notFound(name) { 27 | const error = new ReferenceError(`Component "${name}" not registered`); 28 | const stack = error.stack.split('\n'); 29 | 30 | error.stack = [stack[0]] 31 | .concat( 32 | ` at YOUR-COMPONENT-DID-NOT-REGISTER_${name}:1:1`, 33 | stack.slice(1), 34 | ) 35 | .join('\n'); 36 | 37 | return error; 38 | } 39 | 40 | function msSince(start) { 41 | const diff = process.hrtime(start); 42 | return (diff[0] * 1e3) + (diff[1] / 1e6); 43 | } 44 | 45 | function now() { 46 | return process.hrtime(); 47 | } 48 | 49 | /** 50 | * The BatchManager is a class that is instantiated once per batch, and holds a lot of the 51 | * key data needed throughout the life of the request. This ends up cleaning up some of the 52 | * management needed for plugin lifecycle, and the handling of rendering multiple jobs in a 53 | * batch. 54 | * 55 | * @param {express.Request} req 56 | * @param {express.Response} res 57 | * @param {Object} jobs - a map of token => Job 58 | * @param {Object} config 59 | * @constructor 60 | */ 61 | class BatchManager { 62 | constructor(request, response, jobs, config) { 63 | const tokens = Object.keys(jobs); 64 | 65 | this.config = config; 66 | this.plugins = config.plugins; 67 | this.error = null; 68 | this.statusCode = 200; 69 | 70 | // An object that all of the contexts will inherit from... one per instance. 71 | this.baseContext = { 72 | request, 73 | response, 74 | batchMeta: {}, 75 | }; 76 | 77 | // An object that will be passed into the context for batch-level methods, but not for job-level 78 | // methods. 79 | this.batchContext = { 80 | tokens, 81 | jobs, 82 | }; 83 | 84 | // A map of token => JobContext, where JobContext is an object of data that is per-job, 85 | // and will be passed into plugins and used for the final result. 86 | this.jobContexts = tokens.reduce((obj, token) => { 87 | const { name, data, metadata } = jobs[token]; 88 | /* eslint no-param-reassign: 1 */ 89 | obj[token] = { 90 | name, 91 | token, 92 | props: data, 93 | metadata, 94 | statusCode: 200, 95 | duration: null, 96 | html: null, 97 | returnMeta: {}, 98 | }; 99 | return obj; 100 | }, {}); 101 | 102 | // Each plugin receives it's own little key-value data store that is scoped privately 103 | // to the plugin for the life time of the request. This is achieved simply through lexical 104 | // closure. 105 | this.pluginContexts = new Map(); 106 | this.plugins.forEach((plugin) => { 107 | this.pluginContexts.set(plugin, { data: new Map() }); 108 | }); 109 | } 110 | 111 | /** 112 | * Returns a context object scoped to a specific plugin and job (based on the plugin and 113 | * job token passed in). 114 | */ 115 | getRequestContext(plugin, token) { 116 | return { 117 | ...this.baseContext, 118 | ...this.jobContexts[token], 119 | ...this.pluginContexts.get(plugin), 120 | }; 121 | } 122 | 123 | /** 124 | * Returns a context object scoped to a specific plugin and batch. 125 | */ 126 | getBatchContext(plugin) { 127 | return { 128 | ...this.baseContext, 129 | ...this.batchContext, 130 | ...this.pluginContexts.get(plugin), 131 | }; 132 | } 133 | 134 | contextFor(plugin, token) { 135 | return token ? this.getRequestContext(plugin, token) : this.getBatchContext(plugin); 136 | } 137 | 138 | /** 139 | * Renders a specific job (from a job token). The end result is applied to the corresponding 140 | * job context. Additionally, duration is calculated. 141 | */ 142 | render(token) { 143 | const start = now(); 144 | const context = this.jobContexts[token]; 145 | const { name } = context; 146 | 147 | const { getComponent } = this.config; 148 | 149 | const result = getComponent(name, context); 150 | 151 | return Promise.resolve(result).then((renderFn) => { 152 | // ensure that we have this component registered 153 | if (!renderFn || typeof renderFn !== 'function') { 154 | // component not registered 155 | context.statusCode = 404; 156 | return Promise.reject(notFound(name)); 157 | } 158 | 159 | return renderFn(context.props); 160 | }).then((html) => { // eslint-disable-line consistent-return 161 | if (!html) { 162 | return Promise.reject(noHTMLError); 163 | } 164 | context.html = html; 165 | context.duration = msSince(start); 166 | }).catch((err) => { 167 | context.duration = msSince(start); 168 | return Promise.reject(err); 169 | }); 170 | } 171 | 172 | recordError(error, token) { 173 | if (token && this.jobContexts[token]) { 174 | const context = this.jobContexts[token]; 175 | context.statusCode = context.statusCode === 200 ? 500 : context.statusCode; 176 | context.error = error; 177 | } else { 178 | this.error = error; 179 | this.statusCode = 500; 180 | } 181 | } 182 | 183 | getResult(token) { 184 | const context = this.jobContexts[token]; 185 | return { 186 | name: context.name, 187 | html: context.html, 188 | meta: context.returnMeta, 189 | duration: context.duration, 190 | statusCode: context.statusCode, 191 | success: context.html !== null, 192 | error: context.error ? errorToSerializable(context.error) : null, 193 | }; 194 | } 195 | 196 | getResults() { 197 | return { 198 | success: this.error === null, 199 | error: this.error, 200 | results: Object.keys(this.jobContexts).reduce((result, token) => { 201 | /* eslint no-param-reassign: 1 */ 202 | result[token] = this.getResult(token); 203 | return result; 204 | }, {}), 205 | }; 206 | } 207 | } 208 | 209 | export default BatchManager; 210 | -------------------------------------------------------------------------------- /src/utils/lifecycle.js: -------------------------------------------------------------------------------- 1 | import logger from './logger'; 2 | 3 | const MAX_LIFECYCLE_EXECUTION_TIME_IN_MS = 300; 4 | const PROMISE_TIMEOUT = {}; 5 | 6 | /** 7 | * @typedef {Object} HypernovaPlugin 8 | */ 9 | 10 | /** 11 | * Returns a predicate function to filter objects based on whether a method of the provided 12 | * name is present. 13 | * @param {String} name - the method name to find 14 | * @returns {Function} - the resulting predicate function 15 | */ 16 | export function hasMethod(name) { 17 | return (obj) => typeof obj[name] === 'function'; 18 | } 19 | 20 | /** 21 | * Creates a promise that resolves at the specified number of ms. 22 | * 23 | * @param ms 24 | * @returns {Promise} 25 | */ 26 | export function raceTo(promise, ms, msg) { 27 | let timeout; 28 | 29 | return Promise.race([ 30 | promise, 31 | new Promise((resolve) => { 32 | timeout = setTimeout(() => resolve(PROMISE_TIMEOUT), ms); 33 | }), 34 | ]).then((res) => { 35 | if (res === PROMISE_TIMEOUT) logger.info(msg, { timeout: ms }); 36 | if (timeout) clearTimeout(timeout); 37 | 38 | return res; 39 | }).catch((err) => { 40 | if (timeout) clearTimeout(timeout); 41 | 42 | return Promise.reject(err); 43 | }); 44 | } 45 | 46 | /** 47 | * Iterates through the plugins and calls the specified asynchronous lifecycle event, 48 | * returning a promise that resolves when they all are completed, or rejects if one of them 49 | * fails. 50 | * 51 | * The third `config` param gets passed into the lifecycle methods as the first argument. In 52 | * this case, the app lifecycle events expect the config instance to be passed in. 53 | * 54 | * This function is currently used for the lifecycle events `initialize` and `shutdown`. 55 | * 56 | * @param {String} lifecycle 57 | * @param {Array} plugins 58 | * @param {Config} config 59 | * @returns {Promise} 60 | * @param err {Error} 61 | */ 62 | export function runAppLifecycle(lifecycle, plugins, config, error, ...args) { 63 | try { 64 | const promise = Promise.all( 65 | plugins.filter(hasMethod(lifecycle)).map((plugin) => plugin[lifecycle](config, error, ...args)), 66 | ); 67 | 68 | return raceTo( 69 | promise, 70 | MAX_LIFECYCLE_EXECUTION_TIME_IN_MS, 71 | `App lifecycle method ${lifecycle} took too long.`, 72 | ); 73 | } catch (err) { 74 | return Promise.reject(err); 75 | } 76 | } 77 | 78 | /** 79 | * Iterates through the plugins and calls the specified asynchronous lifecycle event, 80 | * returning a promise that resolves when they are all completed, or rejects if one of them 81 | * fails. 82 | * 83 | * This is meant to be used on lifecycle events both at the batch level and the job level. The 84 | * passed in BatchManager is used to get the corresponding context object for the plugin/job and 85 | * is passed in as the first argument to the plugin's method. 86 | * 87 | * This function is currently used for `batchStart/End` and `jobStart/End`. 88 | * 89 | * @param {String} lifecycle 90 | * @param {Array} plugins 91 | * @param {BatchManager} manager 92 | * @param {String} [token] - If provided, the job token to use to get the context 93 | * @returns {Promise} 94 | */ 95 | export function runLifecycle(lifecycle, plugins, manager, token) { 96 | try { 97 | const promise = Promise.all( 98 | plugins 99 | .filter(hasMethod(lifecycle)) 100 | .map((plugin) => plugin[lifecycle](manager.contextFor(plugin, token))), 101 | ); 102 | 103 | return raceTo( 104 | promise, 105 | MAX_LIFECYCLE_EXECUTION_TIME_IN_MS, 106 | `Lifecycle method ${lifecycle} took too long.`, 107 | ); 108 | } catch (err) { 109 | return Promise.reject(err); 110 | } 111 | } 112 | 113 | /** 114 | * Iterates through the plugins and calls the specified synchronous lifecycle event (when present). 115 | * Passes in the appropriate context object for the plugin/job. 116 | * 117 | * This function is currently being used for `afterRender` and `beforeRender`. 118 | * 119 | * @param {String} lifecycle 120 | * @param {Array} plugins 121 | * @param {BatchManager} manager 122 | * @param {String} [token] 123 | */ 124 | export function runLifecycleSync(lifecycle, plugins, manager, token) { 125 | plugins 126 | .filter(hasMethod(lifecycle)) 127 | .forEach((plugin) => plugin[lifecycle](manager.contextFor(plugin, token))); 128 | } 129 | 130 | /** 131 | * Iterates through the plugins and calls the specified synchronous `onError` handler 132 | * (when present). 133 | * 134 | * Passes in the appropriate context object, as well as the error. 135 | * 136 | * @param {Error} err 137 | * @param {Array} plugins 138 | * @param {BatchManager} manager 139 | * @param {String} [token] 140 | */ 141 | export function errorSync(err, plugins, manager, token) { 142 | plugins 143 | .filter(hasMethod('onError')) 144 | .forEach((plugin) => plugin.onError(manager.contextFor(plugin, token), err)); 145 | } 146 | 147 | /** 148 | * Runs through the job-level lifecycle events of the job based on the provided token. This includes 149 | * the actual rendering of the job. 150 | * 151 | * Returns a promise resolving when the job completes. 152 | * 153 | * @param {String} token 154 | * @param {Array} plugins 155 | * @param {BatchManager} manager 156 | * @returns {Promise} 157 | */ 158 | export function processJob(token, plugins, manager) { 159 | return ( 160 | // jobStart 161 | runLifecycle('jobStart', plugins, manager, token) 162 | 163 | .then(() => { 164 | // beforeRender 165 | runLifecycleSync('beforeRender', plugins, manager, token); 166 | 167 | // render 168 | return manager.render(token); 169 | }) 170 | // jobEnd 171 | .then(() => { 172 | // afterRender 173 | runLifecycleSync('afterRender', plugins, manager, token); 174 | 175 | return runLifecycle('jobEnd', plugins, manager, token); 176 | }) 177 | .catch((err) => { 178 | manager.recordError(err, token); 179 | errorSync(err, plugins, manager, token); 180 | }) 181 | ); 182 | } 183 | 184 | function processJobsSerially(jobs, plugins, manager) { 185 | return Object.keys(jobs).reduce( 186 | (chain, token) => chain.then(() => processJob(token, plugins, manager)), 187 | Promise.resolve(), 188 | ); 189 | } 190 | 191 | function processJobsConcurrently(jobs, plugins, manager) { 192 | return Promise.all( 193 | Object.keys(jobs).map((token) => processJob(token, plugins, manager)), 194 | ); 195 | } 196 | 197 | /** 198 | * Runs through the batch-level lifecycle events of a batch. This includes the processing of each 199 | * individual job. 200 | * 201 | * Returns a promise resolving when all jobs in the batch complete. 202 | * 203 | * @param jobs 204 | * @param {Array} plugins 205 | * @param {BatchManager} manager 206 | * @returns {Promise} 207 | */ 208 | export function processBatch(jobs, plugins, manager, concurrent) { 209 | return ( 210 | // batchStart 211 | runLifecycle('batchStart', plugins, manager) 212 | 213 | // for each job, processJob 214 | .then(() => { 215 | if (concurrent) { 216 | return processJobsConcurrently(jobs, plugins, manager); 217 | } 218 | 219 | return processJobsSerially(jobs, plugins, manager); 220 | }) 221 | 222 | // batchEnd 223 | .then(() => runLifecycle('batchEnd', plugins, manager)) 224 | .catch((err) => { 225 | manager.recordError(err); 226 | errorSync(err, plugins, manager); 227 | }) 228 | ); 229 | } 230 | -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | let logger = null; 4 | 5 | const OPTIONS = { 6 | level: 'info', 7 | colorize: true, 8 | timestamp: true, 9 | prettyPrint: process.env.NODE_ENV !== 'production', 10 | }; 11 | 12 | const loggerInterface = { 13 | init(config, loggerInstance) { 14 | if (loggerInstance) { 15 | logger = loggerInstance; 16 | } else { 17 | const options = { ...OPTIONS, ...config }; 18 | 19 | logger = new winston.Logger({ 20 | transports: [ 21 | new winston.transports.Console(options), 22 | ], 23 | }); 24 | } 25 | 26 | delete loggerInterface.init; 27 | }, 28 | 29 | error(message, meta) { 30 | return logger.log('error', message, meta); 31 | }, 32 | 33 | info(message, meta) { 34 | return logger.log('info', message, meta); 35 | }, 36 | }; 37 | 38 | export default loggerInterface; 39 | -------------------------------------------------------------------------------- /src/utils/renderBatch.js: -------------------------------------------------------------------------------- 1 | import BatchManager from './BatchManager'; 2 | import { processBatch } from './lifecycle'; 3 | import logger from './logger'; 4 | 5 | export default (config, isClosing) => (req, res) => { 6 | // istanbul ignore if 7 | if (isClosing()) { 8 | logger.info('Starting request when closing!'); 9 | } 10 | const jobs = req.body; 11 | 12 | const manager = new BatchManager(req, res, jobs, config); 13 | 14 | return processBatch(jobs, config.plugins, manager, config.processJobsConcurrently) 15 | .then(() => { 16 | // istanbul ignore if 17 | if (isClosing()) { 18 | logger.info('Ending request when closing!'); 19 | } 20 | return res.status(manager.statusCode).json(manager.getResults()).end(); 21 | }) 22 | .catch(() => res.status(manager.statusCode).end()); 23 | }; 24 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | 3 | import './environment'; 4 | import logger from './utils/logger'; 5 | import renderBatch from './utils/renderBatch'; 6 | import { runAppLifecycle, errorSync, raceTo } from './utils/lifecycle'; 7 | import BatchManager from './utils/BatchManager'; 8 | 9 | const attachMiddleware = (app, config) => { 10 | app.use(bodyParser.json(config.bodyParser)); 11 | }; 12 | 13 | const attachEndpoint = (app, config, callback) => { 14 | app.post(config.endpoint, renderBatch(config, callback)); 15 | }; 16 | 17 | function exit(code) { 18 | return () => process.exit(code); 19 | } 20 | 21 | class Server { 22 | constructor(app, config, callback) { 23 | this.server = null; 24 | this.app = app; 25 | this.config = config; 26 | this.callback = callback; 27 | 28 | this.closing = false; 29 | 30 | this.close = this.close.bind(this); 31 | this.errorHandler = this.errorHandler.bind(this); 32 | this.shutDownSequence = this.shutDownSequence.bind(this); 33 | } 34 | 35 | close() { 36 | return new Promise((resolve) => { 37 | if (!this.server) { 38 | resolve(); 39 | return; 40 | } 41 | 42 | try { 43 | this.closing = true; 44 | this.server.close((e) => { 45 | if (e) { logger.info('Ran into error during close', { stack: e.stack }); } 46 | resolve(); 47 | }); 48 | } catch (e) { 49 | logger.info('Ran into error on close', { stack: e.stack }); 50 | resolve(); 51 | } 52 | }); 53 | } 54 | 55 | shutDownSequence(error, req, code = 1) { 56 | if (error) { 57 | logger.info(error.stack); 58 | } 59 | 60 | raceTo(this.close(), 1000, 'Closing the worker took too long.') 61 | .then(() => runAppLifecycle('shutDown', this.config.plugins, this.config, error, req)) 62 | .then(exit(code)) 63 | .catch(exit(code)); 64 | } 65 | 66 | errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars 67 | // If there is an error with body-parser and the status is set then we can safely swallow 68 | // the error and report it. 69 | // Here are a list of errors https://github.com/expressjs/body-parser#errors 70 | if (err.status && err.status >= 400 && err.status < 600) { 71 | logger.info('Non-fatal error encountered.'); 72 | logger.info(err.stack); 73 | 74 | res.status(err.status).end(); 75 | 76 | // In a promise in case one of the plugins throws an error. 77 | new Promise(() => { // eslint-disable-line no-new 78 | const manager = new BatchManager(req, res, req.body, this.config); 79 | errorSync(err, this.config.plugins, manager); 80 | }); 81 | 82 | return; 83 | } 84 | this.shutDownSequence(err, req, 1); 85 | } 86 | 87 | initialize() { 88 | // run through the initialize methods of any plugins that define them 89 | runAppLifecycle('initialize', this.config.plugins, this.config) 90 | .then(() => { 91 | this.server = this.app.listen(...this.config.listenArgs, this.callback); 92 | return null; 93 | }) 94 | .catch(this.shutDownSequence); 95 | } 96 | } 97 | 98 | const initServer = (app, config, callback) => { 99 | const server = new Server(app, config, callback); 100 | 101 | // Middleware 102 | app.use(server.errorHandler); 103 | 104 | // Last safety net 105 | process.on('uncaughtException', server.errorHandler); 106 | 107 | // if all the workers are ready then we should be good to start accepting requests 108 | process.on('message', (msg) => { 109 | if (msg === 'kill') { 110 | server.shutDownSequence(null, null, 0); 111 | } 112 | }); 113 | 114 | server.initialize(); 115 | 116 | return server; 117 | }; 118 | 119 | const worker = (app, config, onServer, workerId) => { 120 | // ===== Middleware ========================================================= 121 | attachMiddleware(app, config); 122 | 123 | if (onServer) { 124 | onServer(app, process); 125 | } 126 | 127 | let server; 128 | 129 | // ===== Routes ============================================================= 130 | // server.closing 131 | attachEndpoint(app, config, () => server && server.closing); 132 | 133 | function registerSignalHandler(sig) { 134 | process.on(sig, () => { 135 | logger.info(`Hypernova worker got ${sig}. Going down`); 136 | server.shutDownSequence(null, null, 0); 137 | }); 138 | } 139 | 140 | // Gracefully shutdown the worker when not running in a cluster (devMode = true) 141 | if (config.devMode) { 142 | ['SIGTERM', 'SIGINT'].map(registerSignalHandler); 143 | } 144 | 145 | // ===== initialize server's nuts and bolts ================================= 146 | server = initServer(app, config, () => { 147 | if (process.send) { 148 | // tell our coordinator that we're ready to start receiving requests 149 | process.send({ workerId, ready: true }); 150 | } 151 | 152 | logger.info('Connected', { listen: config.listenArgs }); 153 | }); 154 | }; 155 | 156 | worker.attachMiddleware = attachMiddleware; 157 | worker.attachEndpoint = attachEndpoint; 158 | worker.initServer = initServer; 159 | worker.Server = Server; 160 | 161 | export default worker; 162 | -------------------------------------------------------------------------------- /test/BatchManager-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import sinon from 'sinon-sandbox'; 3 | 4 | import { makeJob, COMPONENT_NAME } from './helper'; 5 | import BatchManager from '../lib/utils/BatchManager'; 6 | 7 | function mockPlugin() { 8 | return { 9 | initialize: sinon.stub(), 10 | batchStart: sinon.stub(), 11 | jobStart: sinon.stub(), 12 | beforeRender: sinon.stub(), 13 | afterRender: sinon.stub(), 14 | onError: sinon.stub(), 15 | jobEnd: sinon.stub(), 16 | batchEnd: sinon.stub(), 17 | shutDown: sinon.stub(), 18 | }; 19 | } 20 | 21 | const jobs = { 22 | foo: makeJob(), 23 | bar: makeJob(), 24 | baz: { 25 | name: 'baz', 26 | data: {}, 27 | }, 28 | }; 29 | 30 | jobs.bar.name = 'bar'; // component not registered 31 | 32 | const req = {}; 33 | const res = {}; 34 | const _strategies = { 35 | [COMPONENT_NAME]: sinon.stub().returns('html'), 36 | baz: sinon.stub().returns(undefined), 37 | }; 38 | const config = { 39 | getComponent(name) { 40 | return _strategies[name]; 41 | }, 42 | plugins: {}, 43 | }; 44 | 45 | describe('BatchManager', () => { 46 | let plugins; 47 | let manager; 48 | 49 | beforeEach(() => { 50 | plugins = [ 51 | mockPlugin(), 52 | mockPlugin(), 53 | ]; 54 | config.plugins = plugins; 55 | manager = new BatchManager(req, res, jobs, config); 56 | }); 57 | 58 | context('request contexts', () => { 59 | it('returns a plugin data map that persists across the plugin', () => { 60 | const context1 = manager.contextFor(plugins[0], 'foo'); 61 | const context2 = manager.contextFor(plugins[0], 'foo'); 62 | const context3 = manager.contextFor(plugins[1], 'foo'); 63 | 64 | context1.data.set('foo', 'bar'); 65 | assert.equal(context2.data.get('foo'), 'bar'); 66 | assert.isUndefined(context3.data.get('foo')); 67 | }); 68 | 69 | it('contains information about the specific job', () => { 70 | const context1 = manager.contextFor(plugins[0], 'foo'); 71 | assert.equal(context1.token, 'foo'); 72 | const context2 = manager.contextFor(plugins[0], 'bar'); 73 | assert.equal(context2.token, 'bar'); 74 | }); 75 | }); 76 | 77 | context('request contexts', () => { 78 | it('contains information about the batch', () => { 79 | const context1 = manager.contextFor(plugins[0]); 80 | assert.deepEqual(context1.tokens, Object.keys(jobs)); 81 | }); 82 | }); 83 | 84 | describe('.render()', () => { 85 | it('sets the html and duration for the right context', (done) => { 86 | manager.render('foo').then(() => { 87 | const context = manager.jobContexts.foo; 88 | assert.equal(context.html, 'html'); 89 | assert.equal(context.statusCode, 200); 90 | assert.isNotNull(context.duration); 91 | 92 | done(); 93 | }); 94 | }); 95 | 96 | it('fails if component is not registered', (done) => { 97 | manager.render('bar').catch((err) => { 98 | assert.equal(err.message, 'Component "bar" not registered'); 99 | done(); 100 | }); 101 | }); 102 | 103 | it('fails when a component returns falsy html', (done) => { 104 | manager.render('baz').catch((err) => { 105 | assert.equal( 106 | err.message, 107 | 'HTML was not returned to Hypernova, this is most likely an error within your application. Check your logs for any uncaught errors and/or rejections.', 108 | ); 109 | done(); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('.recordError()', () => { 115 | it('sets error and status code for the jobContext, when token is present', () => { 116 | manager.recordError(new Error(), 'foo'); 117 | const context = manager.contextFor(plugins[0], 'foo'); 118 | assert.equal(context.statusCode, 500); 119 | }); 120 | 121 | it('sets error and status code for the batch, when no token is present', () => { 122 | manager.recordError(new Error()); 123 | assert.equal(manager.statusCode, 500); 124 | }); 125 | }); 126 | 127 | describe('.getResult()', () => { 128 | it('returns an object with the html of the right jobContext', (done) => { 129 | manager.render('foo').then(() => { 130 | const result = manager.getResult('foo'); 131 | assert.equal(result.html, 'html'); 132 | assert.isTrue(result.success); 133 | assert.isNull(result.error); 134 | done(); 135 | }); 136 | }); 137 | it('returns an object with the html of the right jobContext', () => { 138 | manager.recordError(new Error(), 'bar'); 139 | const result = manager.getResult('bar'); 140 | assert.isFalse(result.success); 141 | assert.isNotNull(result.error); 142 | }); 143 | }); 144 | 145 | describe('.getResults()', () => { 146 | it('returns an object with keys of tokens of each job', (done) => { 147 | manager.render('foo').then(() => { 148 | manager.recordError(new Error(), 'bar'); 149 | const response = manager.getResults(); 150 | assert.isDefined(response.success); 151 | assert.isDefined(response.error); 152 | assert.isDefined(response.results); 153 | assert.isDefined(response.results.foo); 154 | assert.isDefined(response.results.bar); 155 | assert.equal(response.results.foo.html, 'html'); 156 | 157 | done(); 158 | }); 159 | }); 160 | 161 | it('contains a duration even if there is an error', (done) => { 162 | manager.render('bar').catch(() => { 163 | const response = manager.getResults(); 164 | assert.isDefined(response.results.bar.duration); 165 | assert.isNumber(response.results.bar.duration); 166 | 167 | done(); 168 | }); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/Module-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import has from 'has'; 3 | import { Module } from '../server'; 4 | import mutableArray from './mutableArray'; 5 | 6 | function run(code) { 7 | const name = __filename; 8 | 9 | const module = new Module(name); 10 | module.load(name); 11 | module._compile(code, name); 12 | 13 | return module.exports; 14 | } 15 | 16 | describe('Module', () => { 17 | it('does not leak globals across requests', () => { 18 | global.foo = 10; 19 | const code = ` 20 | global.foo = global.foo || 0; 21 | global.foo += 1; 22 | `; 23 | run(code); 24 | assert(global.foo === 10, 'our environment\'s global was unaffected'); 25 | run(code); 26 | assert(global.foo === 10, 'our environment\'s global was unaffected after a second run'); 27 | }); 28 | 29 | it('loads a module and return the instance', () => { 30 | const module = Module.load('./test/mutableArray.js'); 31 | assert(has(module, 'exports') === true, 'module has exports property'); 32 | assert.isArray(module.exports, 'module.exports is our array'); 33 | }); 34 | 35 | it('should not be able to mutate singletons', () => { 36 | assert(mutableArray.length === 0, 'our array is empty'); 37 | 38 | mutableArray.push(1, 2, 3); 39 | 40 | assert(mutableArray.length === 3, 'our array has a length of 3'); 41 | 42 | const code = ` 43 | var mutableArray = require('./mutableArray'); 44 | mutableArray.push(1); 45 | module.exports = mutableArray; 46 | `; 47 | 48 | const arr = run(code); 49 | 50 | assert(mutableArray !== arr, 'both arrays do not equal each other'); 51 | assert(arr.length === 1, 'returned mutableArray has length of 1'); 52 | assert(mutableArray.length === 3, 'our array still has a length of 3'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/a.js: -------------------------------------------------------------------------------- 1 | global.a = 1; 2 | -------------------------------------------------------------------------------- /test/b.js: -------------------------------------------------------------------------------- 1 | global.b = 2; 2 | export default 2; 3 | -------------------------------------------------------------------------------- /test/checkIso.js: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import { assert } from 'chai'; 3 | 4 | export default { 5 | isIsomorphic(html) { 6 | const $ = cheerio.load(html); 7 | assert.ok(/___iso-html___/.test($('div').first().attr('class')), 'iso html exists'); 8 | assert.ok(/___iso-state___/.test($('div').last().attr('class')), 'iso state exists'); 9 | }, 10 | 11 | isNotIsomorphic(html) { 12 | const $ = cheerio.load(html); 13 | assert.notOk(/___iso-html___/.test($('div').first().attr('class')), 'iso html does not exist'); 14 | assert.notOk(/___iso-state___/.test($('div').last().attr('class')), 'iso state does not exist'); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /test/client-test.js: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import sinon from 'sinon-sandbox'; 3 | import wrap from 'mocha-wrap'; 4 | import { assert } from 'chai'; 5 | import { serialize, load } from '..'; 6 | 7 | function cheerioToDOM($, className) { 8 | return $(className).map((i, cheerioObj) => { 9 | const node = cheerioObj; 10 | node.nodeName = node.name.toUpperCase(); 11 | node.innerHTML = $(node).html(); 12 | node.getAttribute = (attr) => $(node).data(attr.replace('data-', '')); 13 | return node; 14 | })[0]; 15 | } 16 | 17 | wrap().withGlobal('document', () => ({})) 18 | .withGlobal('window', () => ({})) 19 | .describe('hypernova client', () => { 20 | let result; 21 | beforeEach(() => { 22 | result = serialize('Component3', '
Hello World!
', { name: 'Serenity' }); 23 | }); 24 | 25 | it('should load up the DOM', () => { 26 | const $ = cheerio.load(result); 27 | 28 | const spy = sinon.spy(); 29 | 30 | global.document.querySelector = (className) => { 31 | spy(className); 32 | return cheerioToDOM($, className); 33 | }; 34 | global.document.querySelectorAll = (classname) => [cheerioToDOM($, classname)]; 35 | 36 | // Calling it again for the client. 37 | load('Component3'); 38 | 39 | assert.ok(spy.calledOnce, 'our spy was called'); 40 | }); 41 | 42 | it('should not be called unless there is a node', () => { 43 | global.document = { 44 | querySelector() { 45 | return null; 46 | }, 47 | querySelectorAll() { 48 | return []; 49 | }, 50 | }; 51 | 52 | const arr = load('foo'); 53 | 54 | assert.ok(arr.length === 0); 55 | 56 | delete global.document; 57 | }); 58 | 59 | it('should be called if there is a node', () => { 60 | const $ = cheerio.load(result); 61 | 62 | global.document = { 63 | querySelector(className) { 64 | return cheerioToDOM($, className); 65 | }, 66 | querySelectorAll(className) { 67 | return [cheerioToDOM($, className)]; 68 | }, 69 | }; 70 | 71 | load('Component3').forEach(({ node, data }) => { 72 | assert.isDefined(node); 73 | assert.isObject(data, 'state is an object'); 74 | assert.equal(data.name, 'Serenity', 'state obj has proper state'); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/components/HypernovaExample.js: -------------------------------------------------------------------------------- 1 | const hypernova = require('../..'); 2 | 3 | module.exports = hypernova({ 4 | server: function () {}, 5 | client: function () {}, 6 | }); 7 | -------------------------------------------------------------------------------- /test/components/nested/component.bundle.js: -------------------------------------------------------------------------------- 1 | const hypernova = require('../../..'); 2 | 3 | module.exports = hypernova({ 4 | server() {}, 5 | client() {}, 6 | }); 7 | -------------------------------------------------------------------------------- /test/coordinator-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import os from 'os'; 3 | import sinon from 'sinon'; 4 | import { getDefaultCPUs, getWorkerCount } from '../lib/coordinator'; 5 | 6 | describe('coordinator', () => { 7 | it('default method returns correct number of cpus', () => { 8 | assert.equal(getDefaultCPUs(5), 4, 'getDefaultCPUs returns n - 1 CPUs'); 9 | 10 | assert.throws(getDefaultCPUs, TypeError, 'getDefaultCPUs must accept a positive integer'); 11 | 12 | assert.throws(() => { 13 | getDefaultCPUs('three'); 14 | }, TypeError, 'getDefaultCPUs must accept a positive integer'); 15 | 16 | assert.throws(() => { 17 | getDefaultCPUs(0); 18 | }, TypeError, 'getDefaultCPUs must accept a positive integer'); 19 | }); 20 | 21 | it('uses the correct number of cpus', () => { 22 | const sandbox = sinon.sandbox.create(); 23 | const dummyCPUs = Array.from({ length: 5 }, () => ({})); 24 | sandbox.stub(os, 'cpus').returns(dummyCPUs); 25 | 26 | assert.equal(getWorkerCount(), dummyCPUs.length - 1, 'getWorkerCount defaults to all available cpus minus one'); 27 | assert.equal(getWorkerCount(() => 3), 3, 'getWorkerCount uses specified cpus'); 28 | 29 | assert.throws(() => { 30 | getWorkerCount(3); 31 | }, TypeError, 'getCPUs must be a function'); 32 | 33 | assert.throws(() => { 34 | getWorkerCount(() => 'three'); 35 | }, TypeError, 'getCPUs must return a positive integer'); 36 | 37 | assert.throws(() => { 38 | getWorkerCount(() => 0); 39 | }, TypeError, 'getCPUs must return a positive integer'); 40 | 41 | sandbox.restore(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/createGetComponent-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import path from 'path'; 3 | import { createGetComponent } from '../server'; 4 | 5 | describe('createGetComponent', () => { 6 | const files = { 7 | HypernovaExample: path.resolve(path.join('test', 'components', 'HypernovaExample.js')), 8 | }; 9 | 10 | const getComponent = createGetComponent(files); 11 | 12 | it('returns the module if it exists', () => { 13 | const component = getComponent('HypernovaExample'); 14 | assert(component !== null, 'HypernovaExample exists'); 15 | }); 16 | 17 | it('returns null if it does not exist', () => { 18 | const component = getComponent('FooBarBazz'); 19 | assert.isNull(component, 'component does not exist'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/createVM-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { createVM } from '../server'; 3 | 4 | describe('createVM', () => { 5 | let vm; 6 | 7 | beforeEach(() => { 8 | vm = createVM(); 9 | }); 10 | 11 | it('runs the code', () => { 12 | const code = ` 13 | module.exports = 12; 14 | `; 15 | const num = vm.run('test.js', code); 16 | 17 | assert(num === 12, 'returned value was given'); 18 | }); 19 | 20 | it('caches module.exports', () => { 21 | process.foo = 0; 22 | const code = ` 23 | process.foo += 1; 24 | module.exports = process.foo; 25 | `; 26 | 27 | const num = vm.run('test.js', code); 28 | 29 | assert(num === 1, 'the resulting code was incremented'); 30 | 31 | const nextNum = vm.run('test.js', code); 32 | 33 | assert(nextNum === 1, 'the module.exports was cached'); 34 | }); 35 | 36 | it('flushes the cache', () => { 37 | vm.run('test.js', ''); 38 | assert(vm.exportsCache.itemCount === 1, 'the cache has 1 entry'); 39 | vm.exportsCache.reset(); 40 | assert(vm.exportsCache.itemCount === 0, 'the cache was reset'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/escape-test.js: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import wrap from 'mocha-wrap'; 3 | import { assert } from 'chai'; 4 | import { serialize, toScript, fromScript } from '..'; 5 | 6 | describe('escaping', () => { 7 | it('escapes', () => { 8 | const html = serialize('foo', '', { foo: '', bar: '>' }); 9 | 10 | assert.include(html, ' ({})) 16 | .describe('with fromScript', () => { 17 | it('loads the escaped content correctly', () => { 18 | const html = toScript({ a: 'b' }, { foo: '', bar: '>', baz: '&' }); 19 | const $ = cheerio.load(html); 20 | 21 | global.document.querySelector = () => ({ innerHTML: $($('script')[0]).html() }); 22 | 23 | const res = fromScript({ 24 | a: 'b', 25 | }); 26 | 27 | assert.isObject(res); 28 | 29 | assert.equal(res.foo, ''); 30 | assert.equal(res.bar, '>'); 31 | assert.equal(res.baz, '&'); 32 | }); 33 | 34 | it('escapes multiple times the same, with interleaved decoding', () => { 35 | const makeHTML = () => toScript({ attr: 'key' }, { 36 | props: 'yay', 37 | needsEncoding: '" > ', // "needsEncoding" is necessary 38 | }); 39 | const script1 = makeHTML(); 40 | const script2 = makeHTML(); 41 | assert.equal(script1, script2, 'two successive toScripts result in identical HTML'); 42 | 43 | const $ = cheerio.load(script1); 44 | 45 | global.document.querySelector = () => ({ innerHTML: $($('script')[0]).html() }); 46 | 47 | const res = fromScript({ attr: 'key' }); 48 | 49 | const script3 = makeHTML(); 50 | assert.equal( 51 | script1, 52 | script3, 53 | 'third toScript after a fromScript call results in the same HTML', 54 | ); 55 | 56 | assert.isObject(res); 57 | 58 | assert.equal(res.props, 'yay'); 59 | }); 60 | }); 61 | 62 | it('escapes quotes and fixes data attributes', () => { 63 | const markup = toScript({ 64 | 'ZOMG-ok': 'yes', 65 | 'data-x': 'y', 66 | '1337!!!': 'w00t', 67 | '---valid': '', 68 | 'Is this ok?': '', 69 | 'weird-values': '"]', 70 | 'weird-values2': '"""', 71 | }, {}); 72 | 73 | const $ = cheerio.load(markup); 74 | const $node = $('script'); 75 | 76 | assert.isString($node.data('zomg-ok')); 77 | assert.isString($node.data('data-x')); 78 | assert.isString($node.data('1337')); 79 | assert.isString($node.data('---valid')); 80 | assert.isString($node.data('isthisok')); 81 | 82 | assert.equal($node.data('weird-values'), '"]'); 83 | assert.equal($node.data('weird-values2'), '"""'); 84 | 85 | assert.isUndefined($node.data('ZOMG-ok')); 86 | assert.isUndefined($node.data('x')); 87 | assert.isUndefined($node.data('Is this ok?')); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/getFiles-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import path from 'path'; 3 | 4 | import { getFiles } from '../server'; 5 | 6 | describe('getFiles', () => { 7 | it('retrieves files', () => { 8 | const files = getFiles(path.join('test', 'components')); 9 | assert(files.length, 2); 10 | assert.property(files[0], 'name'); 11 | assert.property(files[0], 'path'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | export const COMPONENT_NAME = 'HypernovaExampleReact.js'; 2 | 3 | export function makeJob(props) { 4 | return { 5 | name: COMPONENT_NAME, 6 | data: props, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /test/hypernova-runner-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import sinon from 'sinon-sandbox'; 3 | import wrap from 'mocha-wrap'; 4 | 5 | import hypernova from '..'; 6 | 7 | describe('the runner', () => { 8 | it('runs server if window is not defined', () => { 9 | const server = sinon.spy(); 10 | hypernova({ server }); 11 | assert.ok(server.calledOnce); 12 | }); 13 | 14 | wrap().withGlobal('window', () => ({})).it('runs client when window exists', () => { 15 | const client = sinon.spy(); 16 | hypernova({ client }); 17 | assert.ok(client.calledOnce); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/index-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { DATA_KEY, DATA_ID } from '../lib'; 3 | 4 | describe('hypernova', () => { 5 | it('DATA_KEY constant should be importable', () => { 6 | assert.equal(DATA_KEY, 'hypernova-key'); 7 | }); 8 | 9 | it('DATA_ID constant should be importable', () => { 10 | assert.equal(DATA_ID, 'hypernova-id'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | import 'airbnb-js-shims'; 2 | 3 | import sinon from 'sinon-sandbox'; 4 | 5 | afterEach(() => { 6 | sinon.restore(); 7 | }); 8 | -------------------------------------------------------------------------------- /test/lifecycle-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import sinon from 'sinon'; 3 | 4 | import '../lib/environment'; 5 | import { makeJob } from './helper'; 6 | import * as lifecycle from '../lib/utils/lifecycle'; 7 | import BatchManager from '../lib/utils/BatchManager'; 8 | 9 | function mockPlugin() { 10 | return { 11 | initialize: sinon.stub(), 12 | batchStart: sinon.stub(), 13 | jobStart: sinon.stub(), 14 | beforeRender: sinon.stub(), 15 | afterRender: sinon.stub(), 16 | onError: sinon.stub(), 17 | jobEnd: sinon.stub(), 18 | batchEnd: sinon.stub(), 19 | shutDown: sinon.stub(), 20 | }; 21 | } 22 | 23 | function batchManagerInstance(jobs, plugins) { 24 | return new BatchManager({}, {}, jobs, { plugins }); 25 | } 26 | 27 | describe('lifecycle', () => { 28 | const jobs = { 29 | foo: makeJob({ name: 'foo' }), 30 | bar: makeJob({ name: 'bar' }), 31 | }; 32 | 33 | describe('.runAppLifecycle', () => { 34 | it('runs with sync methods', () => { 35 | const plugin = mockPlugin(); 36 | const config = {}; 37 | 38 | return lifecycle.runAppLifecycle('initialize', [plugin], config) 39 | .then(() => { 40 | assert.propertyVal(plugin.initialize, 'callCount', 1); 41 | assert.deepEqual(plugin.initialize.args[0][0], config); 42 | }); 43 | }); 44 | 45 | it('runs with async methods', () => { 46 | const config = {}; 47 | const plugin = mockPlugin(); 48 | let resolved = false; 49 | 50 | const promise = new Promise((resolve) => { 51 | setTimeout(() => { 52 | resolved = true; 53 | resolve(); 54 | }, 20); 55 | }); 56 | 57 | plugin.initialize = sinon.stub().returns(promise); 58 | 59 | return lifecycle.runAppLifecycle('initialize', [plugin], config) 60 | .then(() => { 61 | assert.propertyVal(plugin.initialize, 'callCount', 1); 62 | assert.deepEqual(plugin.initialize.args[0][0], config); 63 | assert.isTrue(resolved); 64 | }); 65 | }); 66 | 67 | it('runs with multiple plugins', () => { 68 | const config = {}; 69 | const plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 70 | plugins[0].initialize = sinon.stub().returns(Promise.resolve()); 71 | plugins[1].initialize = sinon.stub().returns(Promise.resolve()); 72 | 73 | return lifecycle.runAppLifecycle('initialize', plugins, config) 74 | .then(() => { 75 | assert.equal(plugins[0].initialize.callCount, 1); 76 | assert.deepEqual(plugins[0].initialize.args[0][0], config); 77 | assert.equal(plugins[1].initialize.callCount, 1); 78 | assert.deepEqual(plugins[1].initialize.args[0][0], config); 79 | assert.equal(plugins[2].initialize.callCount, 1); 80 | assert.deepEqual(plugins[2].initialize.args[0][0], config); 81 | }); 82 | }); 83 | }); 84 | 85 | describe('.runLifecycle', () => { 86 | it('runs with sync methods', () => { 87 | const plugin = mockPlugin(); 88 | const manager = batchManagerInstance(jobs, [plugin]); 89 | 90 | return lifecycle.runLifecycle('jobStart', [plugin], manager, 'foo') 91 | .then(() => { 92 | assert.equal(plugin.jobStart.callCount, 1, 'calls the method passed in'); 93 | assert.deepEqual(plugin.jobStart.args[0][0], manager.contextFor(plugin, 'foo')); 94 | }); 95 | }); 96 | 97 | it('runs with async methods', () => { 98 | const plugins = [mockPlugin(), mockPlugin()]; 99 | const manager = batchManagerInstance(jobs, plugins); 100 | 101 | let resolved = false; 102 | const promise = new Promise((resolve) => { 103 | setTimeout(() => { 104 | resolved = true; 105 | resolve(); 106 | }, 20); 107 | }); 108 | plugins[0].jobStart = sinon.stub().returns(promise); 109 | 110 | return lifecycle.runLifecycle('jobStart', plugins, manager, 'foo') 111 | .then(() => { 112 | const context = manager.contextFor(plugins[0], 'foo'); 113 | assert.equal(plugins[0].jobStart.callCount, 1); 114 | assert.deepEqual(plugins[0].jobStart.args[0][0], context); 115 | assert.equal(plugins[1].jobStart.callCount, 1); 116 | assert.deepEqual(plugins[1].jobStart.args[0][0], context); 117 | assert.isTrue(resolved); 118 | }); 119 | }); 120 | 121 | it('runs with promises and sync methods', () => { 122 | const plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 123 | plugins[0].jobStart = sinon.stub().returns(Promise.resolve()); 124 | plugins[1].jobStart = sinon.stub().returns(Promise.resolve()); 125 | const manager = batchManagerInstance(jobs, plugins); 126 | 127 | lifecycle.runLifecycle('jobStart', plugins, manager, 'foo') 128 | .then(() => { 129 | const context = manager.contextFor(plugins[0], 'foo'); 130 | assert.equal(plugins[0].jobStart.callCount, 1); 131 | assert.deepEqual(plugins[0].jobStart.args[0][0], context); 132 | assert.equal(plugins[1].jobStart.callCount, 1); 133 | assert.deepEqual(plugins[1].jobStart.args[0][0], context); 134 | assert.equal(plugins[2].jobStart.callCount, 1); 135 | assert.deepEqual(plugins[2].jobStart.args[0][0], context); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('.runLifecycleSync', () => { 141 | it('runs methods synchronously', () => { 142 | const plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 143 | const manager = batchManagerInstance(jobs, plugins); 144 | lifecycle.runLifecycleSync('beforeRender', plugins, manager, 'foo'); 145 | const context = manager.contextFor(plugins[0], 'foo'); 146 | assert.equal(plugins[0].beforeRender.callCount, 1); 147 | assert.deepEqual(plugins[0].beforeRender.args[0][0], context); 148 | 149 | assert.equal(plugins[1].beforeRender.callCount, 1); 150 | assert.deepEqual(plugins[1].beforeRender.args[0][0], context); 151 | 152 | assert.equal(plugins[2].beforeRender.callCount, 1); 153 | assert.deepEqual(plugins[2].beforeRender.args[0][0], context); 154 | }); 155 | }); 156 | 157 | describe('.errorSync', () => { 158 | it('calls onError synchronously with error object', () => { 159 | const err = new Error('message'); 160 | const plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 161 | const manager = batchManagerInstance(jobs, plugins); 162 | lifecycle.errorSync(err, plugins, manager, 'foo'); 163 | 164 | const context = manager.contextFor(plugins[0], 'foo'); 165 | assert.equal(plugins[0].onError.callCount, 1); 166 | assert.deepEqual(plugins[0].onError.args[0][0], context); 167 | assert.equal(plugins[0].onError.args[0][1], err); 168 | 169 | assert.equal(plugins[1].onError.callCount, 1); 170 | assert.deepEqual(plugins[1].onError.args[0][0], context); 171 | assert.equal(plugins[1].onError.args[0][1], err); 172 | 173 | assert.equal(plugins[1].onError.callCount, 1); 174 | assert.deepEqual(plugins[1].onError.args[0][0], context); 175 | assert.equal(plugins[1].onError.args[0][1], err); 176 | }); 177 | }); 178 | 179 | describe('.processJob', () => { 180 | let plugins; 181 | let manager; 182 | 183 | beforeEach(() => { 184 | plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 185 | manager = batchManagerInstance(jobs, plugins); 186 | 187 | sinon.stub(manager, 'render'); 188 | sinon.stub(manager, 'recordError'); 189 | }); 190 | 191 | it('calls lifecycle methods in correct order', () => ( 192 | lifecycle.processJob('foo', plugins, manager).then(() => { 193 | sinon.assert.callOrder( 194 | plugins[0].jobStart, 195 | plugins[1].jobStart, 196 | plugins[2].jobStart, 197 | 198 | plugins[0].beforeRender, 199 | plugins[1].beforeRender, 200 | plugins[2].beforeRender, 201 | 202 | manager.render, 203 | 204 | plugins[0].afterRender, 205 | plugins[1].afterRender, 206 | plugins[2].afterRender, 207 | 208 | plugins[0].jobEnd, 209 | plugins[1].jobEnd, 210 | plugins[2].jobEnd, 211 | ); 212 | }) 213 | )); 214 | 215 | it('calls plugin methods with proper arguments', () => { 216 | const contexts = [ 217 | manager.contextFor(plugins[0], 'foo'), 218 | manager.contextFor(plugins[1], 'foo'), 219 | manager.contextFor(plugins[2], 'foo'), 220 | ]; 221 | 222 | return lifecycle.processJob('foo', plugins, manager).then(() => { 223 | sinon.assert.calledWith(plugins[0].jobStart, contexts[0]); 224 | sinon.assert.calledWith(plugins[1].jobStart, contexts[1]); 225 | sinon.assert.calledWith(plugins[2].jobStart, contexts[2]); 226 | 227 | sinon.assert.calledWith(plugins[0].beforeRender, contexts[0]); 228 | sinon.assert.calledWith(plugins[1].beforeRender, contexts[1]); 229 | sinon.assert.calledWith(plugins[2].beforeRender, contexts[2]); 230 | 231 | sinon.assert.calledWith(manager.render, 'foo'); 232 | 233 | sinon.assert.calledWith(plugins[0].afterRender, contexts[0]); 234 | sinon.assert.calledWith(plugins[1].afterRender, contexts[1]); 235 | sinon.assert.calledWith(plugins[2].afterRender, contexts[2]); 236 | 237 | sinon.assert.calledWith(plugins[0].jobEnd, contexts[0]); 238 | sinon.assert.calledWith(plugins[1].jobEnd, contexts[1]); 239 | sinon.assert.calledWith(plugins[2].jobEnd, contexts[2]); 240 | }); 241 | }); 242 | 243 | it('on an error, fails fast', () => { 244 | plugins[0].beforeRender = sinon.stub().throws(); 245 | 246 | return lifecycle.processJob('foo', plugins, manager) 247 | .then(() => { 248 | sinon.assert.called(plugins[0].jobStart); 249 | sinon.assert.called(plugins[1].jobStart); 250 | sinon.assert.called(plugins[2].jobStart); 251 | 252 | sinon.assert.notCalled(plugins[0].jobEnd); 253 | sinon.assert.notCalled(plugins[1].jobEnd); 254 | sinon.assert.notCalled(plugins[2].jobEnd); 255 | }); 256 | }); 257 | 258 | it('on an error, calls manager.recordError', () => { 259 | plugins[0].beforeRender = sinon.stub().throws(); 260 | 261 | return lifecycle.processJob('foo', plugins, manager).then(() => { 262 | sinon.assert.called(manager.recordError); 263 | }); 264 | }); 265 | 266 | it('on an error, calls onError for plugins', () => { 267 | plugins[0].beforeRender = sinon.stub().throws(); 268 | 269 | return lifecycle.processJob('foo', plugins, manager) 270 | .then(() => { 271 | sinon.assert.called(plugins[0].onError); 272 | sinon.assert.called(plugins[1].onError); 273 | sinon.assert.called(plugins[2].onError); 274 | }); 275 | }); 276 | }); 277 | 278 | describe('.processBatch', () => { 279 | let plugins; 280 | let manager; 281 | 282 | beforeEach(() => { 283 | plugins = [mockPlugin(), mockPlugin(), mockPlugin()]; 284 | manager = batchManagerInstance(jobs, plugins); 285 | 286 | sinon.stub(manager, 'render'); 287 | sinon.stub(manager, 'recordError'); 288 | }); 289 | 290 | [true, false].forEach((concurrent) => { 291 | describe(`when concurrent is ${concurrent}`, () => { 292 | it('calls lifecycle methods in correct order', () => ( 293 | lifecycle.processBatch(jobs, plugins, manager, concurrent) 294 | .then(() => { 295 | sinon.assert.callOrder( 296 | plugins[0].batchStart, 297 | plugins[1].batchStart, 298 | plugins[2].batchStart, 299 | 300 | // gets called once for each job 301 | manager.render, 302 | manager.render, 303 | 304 | plugins[0].batchEnd, 305 | plugins[1].batchEnd, 306 | plugins[2].batchEnd, 307 | ); 308 | }) 309 | )); 310 | 311 | it('on an error, fails fast', () => { 312 | plugins[0].batchStart = sinon.stub().throws(); 313 | 314 | return lifecycle.processBatch(jobs, plugins, manager, concurrent) 315 | .then(() => { 316 | sinon.assert.called(plugins[0].batchStart); 317 | sinon.assert.notCalled(manager.render); 318 | sinon.assert.notCalled(plugins[0].batchEnd); 319 | }); 320 | }); 321 | 322 | it('on an error, calls manager.recordError', () => { 323 | plugins[0].batchStart = sinon.stub().throws(); 324 | 325 | return lifecycle.processBatch(jobs, plugins, manager, concurrent) 326 | .then(() => { 327 | sinon.assert.called(manager.recordError); 328 | }); 329 | }); 330 | 331 | it('on an error, calls onError for plugins', () => { 332 | plugins[0].batchStart = sinon.stub().throws(); 333 | 334 | return lifecycle.processBatch(jobs, plugins, manager, concurrent) 335 | .then(() => { 336 | sinon.assert.called(plugins[0].onError); 337 | sinon.assert.called(plugins[1].onError); 338 | sinon.assert.called(plugins[2].onError); 339 | }); 340 | }); 341 | }); 342 | }); 343 | }); 344 | }); 345 | -------------------------------------------------------------------------------- /test/loadModules-test.js: -------------------------------------------------------------------------------- 1 | import { loadModules, createVM } from '../server'; 2 | 3 | describe('loadModules', () => { 4 | it('loads the respective environment', () => { 5 | const environment = loadModules(require, [ 6 | './a.js', 7 | './b.js', 8 | ]); 9 | 10 | const vm = createVM({ 11 | environment, 12 | }); 13 | 14 | vm.run('test/loadModules-test.js', ` 15 | const assert = require('chai').assert; 16 | 17 | assert.isDefined(global.a); 18 | assert.isDefined(global.b); 19 | `); 20 | }); 21 | 22 | it('works if one module is passed', () => { 23 | const environment = loadModules(require, [ 24 | './a.js', 25 | ]); 26 | 27 | const vm = createVM({ 28 | environment, 29 | }); 30 | 31 | vm.run('test/loadModules-test.js', ` 32 | const assert = require('chai').assert; 33 | 34 | assert.isDefined(global.a); 35 | `); 36 | }); 37 | 38 | it('still works if a module that does not exist is passed', () => { 39 | const environment = loadModules(require, [ 40 | './a.js', 41 | './does-not-exist.js', 42 | ]); 43 | 44 | const vm = createVM({ 45 | environment, 46 | }); 47 | 48 | vm.run('test/loadModules-test.js', ` 49 | const assert = require('chai').assert; 50 | 51 | assert.isDefined(global.a); 52 | `); 53 | }); 54 | 55 | it('still works if a module that does not exist is passed in first', () => { 56 | const environment = loadModules(require, [ 57 | './does-not-exist.js', 58 | './a.js', 59 | ]); 60 | 61 | const vm = createVM({ 62 | environment, 63 | }); 64 | 65 | vm.run('test/loadModules-test.js', ` 66 | const assert = require('chai').assert; 67 | 68 | assert.isDefined(global.a); 69 | `); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/mutableArray.js: -------------------------------------------------------------------------------- 1 | module.exports = []; 2 | -------------------------------------------------------------------------------- /test/renderBatch-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import renderBatch from '../lib/utils/renderBatch'; 3 | 4 | /* eslint max-classes-per-file: 1 */ 5 | 6 | class Response { 7 | status(status) { 8 | this._status = status; 9 | return this; 10 | } 11 | 12 | json(res) { 13 | this._json = res; 14 | return this; 15 | } 16 | 17 | end() {} // eslint-disable-line class-methods-use-this 18 | 19 | getResponse() { 20 | return { 21 | status: this._status, 22 | json: this._json, 23 | }; 24 | } 25 | } 26 | 27 | class Request { 28 | constructor() { 29 | this.body = { 30 | a: { 31 | name: 'HypernovaExample', 32 | data: {}, 33 | }, 34 | }; 35 | } 36 | } 37 | 38 | function makeExpress() { 39 | const req = new Request(); 40 | const res = new Response(); 41 | 42 | return { req, res }; 43 | } 44 | 45 | describe('renderBatch', () => { 46 | [true, false].forEach((processJobsConcurrently) => { 47 | describe(`when processJobsConcurrently is ${processJobsConcurrently}`, () => { 48 | it('returns a batch properly', (done) => { 49 | const expressRoute = renderBatch({ 50 | getComponent() { 51 | return null; 52 | }, 53 | plugins: [], 54 | processJobsConcurrently, 55 | }, () => false); 56 | 57 | const { req, res } = makeExpress(); 58 | 59 | expressRoute(req, res).then(() => { 60 | assert.isObject(res.getResponse()); 61 | 62 | const { status, json } = res.getResponse(); 63 | 64 | assert.isDefined(status); 65 | 66 | assert.equal(status, 200); 67 | 68 | assert.isTrue(json.success); 69 | assert.isNull(json.error); 70 | 71 | const { a } = json.results; 72 | assert.isDefined(a); 73 | assert.property(a, 'html'); 74 | assert.property(a, 'meta'); 75 | assert.property(a, 'duration'); 76 | assert.property(a, 'success'); 77 | assert.property(a, 'error'); 78 | 79 | done(); 80 | }); 81 | }); 82 | 83 | it('rejects a Promise with a string and its ok', (done) => { 84 | const expressRoute = renderBatch({ 85 | getComponent() { 86 | return Promise.reject('Nope'); 87 | }, 88 | plugins: [], 89 | processJobsConcurrently, 90 | }, () => false); 91 | 92 | const { req, res } = makeExpress(); 93 | 94 | expressRoute(req, res).then(() => { 95 | const { json } = res.getResponse(); 96 | const { a } = json.results; 97 | 98 | assert.equal(a.error.name, 'Error'); 99 | assert.equal(a.error.message, 'Nope'); 100 | 101 | done(); 102 | }); 103 | }); 104 | 105 | it('rejects a Promise with a ReferenceError', (done) => { 106 | const expressRoute = renderBatch({ 107 | getComponent() { 108 | return Promise.reject(new ReferenceError()); 109 | }, 110 | plugins: [], 111 | processJobsConcurrently, 112 | }, () => false); 113 | 114 | const { req, res } = makeExpress(); 115 | 116 | expressRoute(req, res).then(() => { 117 | const { json } = res.getResponse(); 118 | const { a } = json.results; 119 | 120 | assert.equal(a.error.name, 'ReferenceError'); 121 | 122 | done(); 123 | }); 124 | }); 125 | 126 | it('rejects a Promise with an Array', (done) => { 127 | const expressRoute = renderBatch({ 128 | getComponent() { 129 | return Promise.reject([1, 2, 3]); 130 | }, 131 | plugins: [], 132 | processJobsConcurrently, 133 | }, () => false); 134 | 135 | const { req, res } = makeExpress(); 136 | 137 | expressRoute(req, res).then(() => { 138 | const { json } = res.getResponse(); 139 | const { a } = json.results; 140 | 141 | assert.equal(a.error.name, 'Error'); 142 | assert.equal(a.error.message, '1,2,3'); 143 | 144 | done(); 145 | }); 146 | }); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/server-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import express from 'express'; 3 | 4 | describe('Hypernova server', () => { 5 | let hypernova; 6 | const getComponent = () => {}; 7 | 8 | beforeEach(() => { 9 | try { 10 | [ 11 | '../lib/utils/logger', 12 | '../lib/worker', 13 | '../lib/server', 14 | '../server', 15 | ].forEach((module) => delete require.cache[require.resolve(module)]); 16 | 17 | hypernova = require('../server'); // eslint-disable-line global-require 18 | } catch (e) { 19 | console.error('Couldnt remove dependecy or load the hypernova module.'); 20 | } 21 | }); 22 | 23 | it('blows up if hypernova does not get getComponent', () => { 24 | assert.throws(hypernova, TypeError); 25 | }); 26 | 27 | it('blows up if hypernova gets `createApplication` that isnt a function', () => { 28 | assert.throws( 29 | () => hypernova({ 30 | devMode: true, 31 | getComponent, 32 | createApplication: {} }), 33 | TypeError); 34 | }); 35 | 36 | it('blows up if hypernova gets `createApplication` that doesnt return an express app', () => { 37 | assert.throws( 38 | () => hypernova({ 39 | devMode: true, 40 | getComponent, 41 | createApplication: () => {} }), 42 | TypeError); 43 | }); 44 | 45 | it('starts up the hypernova server without blowing up', () => { 46 | hypernova({ devMode: true, getComponent }); 47 | }); 48 | 49 | it('starts up the hypernova server and an express instance without blowing up', () => { 50 | const APP_TITLE = 'my custom express instance'; 51 | 52 | const createApplication = () => { 53 | const app = express(); 54 | app.locals.name = APP_TITLE; 55 | 56 | return app; 57 | }; 58 | 59 | const hypernovaServer = hypernova({ 60 | devMode: true, 61 | getComponent, 62 | createApplication, 63 | port: 8090, 64 | }); 65 | 66 | assert.equal(APP_TITLE, hypernovaServer.locals.name); 67 | }); 68 | }); 69 | --------------------------------------------------------------------------------