├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── rails-react-ssr.rb ├── rails_react_ssr.rb └── rails_react_ssr │ ├── errors.rb │ ├── server_runner.rb │ ├── version.rb │ └── webpacker_utils.rb ├── rails-react-ssr.gemspec └── test ├── server_runner_test.rb ├── test_app ├── Rakefile ├── app │ └── javascript │ │ └── packs │ │ └── application.js ├── bin │ ├── webpack │ └── webpack-dev-server ├── config.ru ├── config │ ├── application.rb │ ├── environment.rb │ ├── webpack │ │ └── development.js │ ├── webpacker.yml │ └── webpacker_public_root.yml ├── package.json ├── public │ └── packs │ │ ├── application-k344a6d59eef8632c9d1.js │ │ └── manifest.json └── yarn.lock ├── test_helper.rb └── webpacker_utils_test.rb /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | minitest: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | ruby-version: ['2.7', '3.0', '3.1'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | bundler-cache: true 25 | 26 | - name: Run tests 27 | run: bundle exec rake test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # RubyMine 11 | /.idea/ 12 | 13 | # Test app 14 | /test/test_app/log 15 | /test/test_app/node_modules 16 | 17 | Gemfile.lock 18 | 19 | # Built gems 20 | *.gem 21 | 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.3 7 | before_install: gem install bundler -v 1.17.3 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jefawks3@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in rails-react-ssr.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 James Fawks 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/rails-react-ssr.svg)](https://badge.fury.io/rb/rails-react-ssr) 2 | ![tests](https://github.com/jefawks3/rails-react-ssr/actions/workflows/tests.yml/badge.svg) 3 | 4 | # RailsReactSSR 5 | 6 | RailsReactSSR is a light weight JS server side rendering utility that takes advantage of `Webpacker` and `NodeJS`. 7 | 8 | ## Motivation 9 | 10 | In my latest project I designed my application to use Rails for my API endpoints and `ReactJS` with `react-router` to 11 | handle routing and handle the front end. I needed a basic tool that would not add a lot of bloat, be able to handle 12 | server side rendering while allowing me to process the response (i.e. handle redirects from the router) and did not 13 | force me to use any packages or make decisions for me on how to structure my ReactJS code. 14 | 15 | ## Dependencies 16 | 17 | - [Ruby On Rails](https://rubyonrails.org/) 18 | - [Webpacker](https://github.com/rails/webpacker) 19 | - [NodeJS](https://nodejs.org/) 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ```ruby 26 | gem 'rails-react-ssr' 27 | ``` 28 | 29 | And then execute: 30 | 31 | $ bundle install 32 | 33 | Or install it yourself as: 34 | 35 | $ gem install rails-react-ssr 36 | 37 | ## Usage 38 | 39 | `RailsReactSSR::ServerRunner.exec!(bundle, props:, outputTemp:, max_tries:, delay:)` 40 | 41 | * `bundle` is the path or name of the bundle in the `app/javascript/packs` directory 42 | 43 | (optional) 44 | 45 | * `props` is a hash that will converted to a JSON plain object and passed to the server 46 | * `outputTemp` is either: 47 | * a boolean, where true will output the compiled server code to `tmp/ssr/[bundle].js` 48 | * a string that is the full path to the file to write to 49 | * `max_tries` is the number of retries when fetching the bundle from teh `webpack-dev-server` 50 | * `delay` is the time in ms between each retry 51 | 52 | #### Basic usage 53 | 54 | ##### `server.js` 55 | ```typescript jsx 56 | // Some processing here 57 | 58 | stdout(yourHtmlOutput); 59 | ``` 60 | 61 | ##### Your controller 62 | ```ruby 63 | def index 64 | render html: RailsReactSSR::ServerRunner.exec!('server.js') 65 | end 66 | ``` 67 | 68 | #### Passing properties to the server 69 | 70 | ##### From the controller: 71 | 72 | ```ruby 73 | def index 74 | render html: RailsReactSSR::ServerRunner.exec!('server.js', props: {current_user: current_user}) 75 | end 76 | ``` 77 | 78 | ##### From the server code: 79 | 80 | ```javascript 81 | ... 82 | 83 | // Do something with the user 84 | console.log('Current user', serverProps.currentUser.username); 85 | 86 | ... 87 | ``` 88 | 89 | The keys in the properties passed to the server will be transformed to camelized strings. 90 | 91 | #### Handling redirects with `React-Router-Web` 92 | 93 | Below is an example of handling redirects with [`react-router`](https://reacttraining.com/react-router/). 94 | The principle should be the same for any routing packages. 95 | 96 | ##### `server.js` 97 | ```typescript jsx 98 | // Not the complete story 99 | 100 | const context = {}; 101 | 102 | const RedirectWithStatus = ({from, to, status}) => { 103 | return ( 104 | { 106 | // there is no `staticContext` on the client, so 107 | // we need to guard against that here 108 | if (staticContext) staticContext.status = status; 109 | return ; 110 | }} 111 | /> 112 | ); 113 | } 114 | 115 | const markup = ReactDOMServer.renderToString( 116 | 117 | 118 | 122 | 127 | 128 | 129 | ); 130 | 131 | const output = { 132 | html: markup, 133 | logs: recordedLogs, 134 | redirect: context.url, 135 | status: context.status 136 | }; 137 | 138 | stdout(JSON.stringify(output)); 139 | ``` 140 | More details on SSR and `react-router` at https://reacttraining.com/react-router/web/guides/server-rendering 141 | 142 | ##### Your controller 143 | ```ruby 144 | def index 145 | output = RailsReactSSR::ServerRunner.exec!('server.js', props: {current_user: current_user, location: request.fullpath}) 146 | 147 | react_response = ActiveSupport::JSON.decode output.split(/[\r\n]+/).reject(&:empty?).last 148 | 149 | react_response.deep_symbolize_keys! 150 | 151 | if react_response[:redirect] 152 | redirect_to react_response[:redirect], status: 302 153 | else 154 | render html: react_response[:html] 155 | end 156 | end 157 | ``` 158 | 159 | ### Caching Example 160 | 161 | To improve the response time from the server, you should consider caching. 162 | 163 | Things to consider: 164 | 1) Using a cache key that is not the same for every route if you are using a JS routing package. 165 | 2) How large the response is form the JS server. 166 | 167 | ```ruby 168 | 169 | def index 170 | ## Do something to the path to generate a key that represents it in the server routes 171 | cache_key = generate_cache_key_from_uri request.fullpath 172 | 173 | output = Rails.cache.fetch cache_key, expires: 12.hours, race_condition_ttl: 1.minute, namespace: :react_server do 174 | RailsReactSSR::ServerRunner.exec!('server.js', props: {current_user: current_user, location: request.fullpath}) 175 | end 176 | 177 | handle_server_response output 178 | end 179 | 180 | ``` 181 | 182 | ## Common Issues with SSR and Rails 183 | 184 | ### I'm unable to execute code with webpacker-dev-server running. 185 | 186 | The `webpacker-dev-server` injects a websocket when `inline` or `hmr` flags are set to true in for the `dev_server` 187 | configuration in `webpacker.yml`. Make sure these are set to **false** if you plan on implementing SSR. 188 | 189 | ### `document` or `window` is not defined 190 | 191 | Global objects like `document` or `window` that are specific to browsers are not set when running the javascript on 192 | the server; so it's best to wrap any code, or avoid using it outside of `componentDidMount`, `componentDidUpdate` or 193 | `componentWillUnmount`. 194 | 195 | ## Alternatives 196 | 197 | There are several alternatives that are more comprehensive and might be a better fit for your use case: 198 | 199 | 1) [ReactOnRails](https://github.com/shakacode/react_on_rails) 200 | 2) [react-rails](https://github.com/reactjs/react-rails) 201 | 3) [reactssr-rails](https://github.com/towry/reactssr-rails) 202 | 203 | ## Issues 204 | 205 | Report bugs at https://github.com/jefawks3/rails-react-ssr. 206 | Please make sure to include how to reproduce the issue, otherwise it might be ignored. 207 | 208 | ## Contributing 209 | 210 | 1) Fork it (https://github.com/jefawks3/rails-react-ssr) 211 | 2) Create your feature branch (git checkout -b my-new-feature) 212 | 3) Commit your changes (git commit -am 'Add some feature') 213 | 4) Push to the branch (git push origin my-new-feature) 214 | 5) Create a new Pull Request 215 | 216 | ## License 217 | 218 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 219 | 220 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs << "test" 6 | t.test_files = FileList["test/**/*_test.rb"] 7 | t.verbose = true 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /lib/rails-react-ssr.rb: -------------------------------------------------------------------------------- 1 | require 'rails_react_ssr' 2 | -------------------------------------------------------------------------------- /lib/rails_react_ssr.rb: -------------------------------------------------------------------------------- 1 | require 'rails_react_ssr/errors' 2 | require 'rails_react_ssr/webpacker_utils' 3 | require 'rails_react_ssr/server_runner' 4 | 5 | module RailsReactSSR 6 | 7 | end -------------------------------------------------------------------------------- /lib/rails_react_ssr/errors.rb: -------------------------------------------------------------------------------- 1 | module RailsReactSSR 2 | # RailsReactSSR error 3 | class Error < StandardError 4 | end 5 | 6 | # Bundle errors 7 | class BundleError < Error 8 | attr_reader :bundle 9 | 10 | def initialize(bundle, *args) 11 | super *args 12 | 13 | @bundle = bundle 14 | end 15 | end 16 | 17 | # Missing bundle package 18 | class MissingBundleError < BundleError 19 | end 20 | 21 | # Execution Error 22 | class ExecutionError < BundleError 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rails_react_ssr/server_runner.rb: -------------------------------------------------------------------------------- 1 | require 'webpacker' 2 | require 'shellwords' 3 | require 'active_support/json' 4 | require 'open-uri' 5 | require 'rails' 6 | require 'open3' 7 | 8 | module RailsReactSSR 9 | ## 10 | # Executes the ReactJS package using NodeJS that was built using webpacker 11 | class ServerRunner 12 | ## 13 | # Redirect console output to be logged by an array 14 | CONSOLE_POLYFILL = <<-CONSOLE_POLYFILL 15 | const stdout = console.log; 16 | const stderr = console.error; 17 | 18 | const recordedLogs = []; 19 | 20 | ['log', 'info', 'debug', 'warn', 'error'].forEach(level => { 21 | console[level] = (...args) => { 22 | recordedLogs.push({ level: level, args: args }); 23 | } 24 | }); 25 | CONSOLE_POLYFILL 26 | 27 | ## 28 | # Execute the bundled package 29 | # 30 | # :props - The properties to pass to the server JS code 31 | # :outputTemp - If true, output the compiled bundle to the tmp/ssr directory, pass a string to specify the 32 | # output file 33 | # :max_tries - The number of tries when getting the bundle from the webpack dev server 34 | # :delay - The delay in ms between tries 35 | def self.exec!(bundle, props: {}, outputTemp: false, max_tries: 10, delay: 1000) 36 | bundle_file = RailsReactSSR::WebpackerUtils.open_bundle bundle, max_tries: max_tries, delay: delay 37 | 38 | status = 0 39 | output = nil 40 | 41 | begin 42 | js = Tempfile.new [File.basename(bundle_file, '.*'), File.extname(bundle_file)] 43 | 44 | begin 45 | write_console_polyfill js 46 | write_props_polyfill js, props 47 | write_bundle js, bundle_file 48 | 49 | js.flush 50 | 51 | if outputTemp 52 | outputTemp = Rails.root.join('tmp/ssr/', bundle) if outputTemp.is_a? TrueClass 53 | 54 | Rails.logger.debug "Coping server bundle to #{outputTemp}" 55 | IO.copy_stream js.path, outputTemp 56 | end 57 | 58 | status, output = exec_contents js 59 | ensure 60 | js.unlink 61 | end 62 | rescue => e 63 | Rails.logger.error "Unable to execute the bundle '#{bundle}': #{e.message}" 64 | raise RailsReactSSR::ExecutionError.new(bundle, "Unable to run the bundle '#{bundle}'") 65 | ensure 66 | bundle_file.close 67 | end 68 | 69 | raise RailsReactSSR::ExecutionError.new(bundle,"Unable to execute the server bundle #{bundle}") unless status.zero? 70 | 71 | output 72 | end 73 | 74 | private 75 | 76 | def self.exec_contents(file) 77 | output = error = '' 78 | 79 | cmd = ['node', Shellwords.escape(file.path)] 80 | 81 | cmd_str = cmd.join ' ' 82 | 83 | status = Open3.popen3 cmd_str do |inp, out, err, thr| 84 | output = out.read 85 | error = err.read 86 | 87 | Rails.logger.info "[#{thr.value.exitstatus}}] #{cmd_str}" 88 | Rails.logger.debug output 89 | Rails.logger.error error unless error.nil? || error.empty? 90 | 91 | thr.value.exitstatus 92 | end 93 | 94 | [status, output] 95 | end 96 | 97 | def self.write_props_polyfill(temp_file, props) 98 | ## Format the properties for js 99 | jsProps = props.inject({}) do |hash,(k,v)| 100 | hash[k.to_s.camelcase.gsub(/\A./, &:downcase)] = v 101 | hash 102 | end 103 | 104 | temp_file.write <<-JS 105 | const serverProps = #{ActiveSupport::JSON.encode jsProps}; 106 | 107 | JS 108 | end 109 | 110 | def self.write_console_polyfill(temp_file) 111 | temp_file.write CONSOLE_POLYFILL 112 | temp_file.write "\n\n" 113 | end 114 | 115 | def self.write_bundle(temp_file, bundle_file) 116 | IO.copy_stream bundle_file, temp_file 117 | end 118 | 119 | end 120 | end -------------------------------------------------------------------------------- /lib/rails_react_ssr/version.rb: -------------------------------------------------------------------------------- 1 | module RailsReactSSR 2 | VERSION = "0.1.2" 3 | end 4 | -------------------------------------------------------------------------------- /lib/rails_react_ssr/webpacker_utils.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module RailsReactSSR 4 | class WebpackerUtils 5 | ## 6 | # Return the hashed name from the +bundle+ 7 | def self.hashed_bundle_name!(bundle) 8 | Webpacker.manifest.lookup! bundle 9 | rescue Webpacker::Manifest::MissingEntryError 10 | raise RailsReactSSR::MissingBundleError.new(bundle, "The ReactJS package '#{bundle}' is missing from the manifest.json file.") 11 | end 12 | 13 | ## 14 | # Open the +bundle+ file for reading 15 | # 16 | # Returns IO stream with the +bundle+ contents. If +bundle+ cannot be found, 17 | # raises +RailsReactSSR::MissingBundleError+ 18 | def self.open_bundle(bundle, max_tries: 10, delay: 1000) 19 | hashed = hashed_bundle_name! bundle 20 | 21 | if Webpacker.dev_server.running? 22 | dev_server_bundle hashed, max_tries, delay 23 | else 24 | local_file_bundle hashed 25 | end 26 | end 27 | 28 | private 29 | 30 | def self.dev_bundle_uri(path) 31 | URI::Generic.new( 32 | Webpacker.dev_server.protocol, 33 | nil, 34 | Webpacker.dev_server.host, 35 | Webpacker.dev_server.port, 36 | nil, 37 | path, 38 | nil, 39 | nil, 40 | nil 41 | ).to_s 42 | end 43 | 44 | def self.bundle_fullpath(path) 45 | File.join Rails.root, 'public', path 46 | end 47 | 48 | def self.dev_server_bundle(hashed_bundle, max_tries, delay, tries = 0) 49 | tries += 1 50 | 51 | uri = self.dev_bundle_uri hashed_bundle 52 | 53 | Rails.logger.debug "Reading remote bundle #{uri}" 54 | 55 | open uri 56 | rescue OpenURI::HTTPError => e 57 | # On the first page hit my not be available on the dev server so we need to wait for it to compile 58 | if tries < max_tries 59 | Rails.logger.debug "The remote bundle is not ready trying again in #{delay}ms - #{tries} of #{max_tries}" 60 | sleep delay / 1000 61 | retry 62 | else 63 | raise e 64 | end 65 | end 66 | 67 | def self.local_file_bundle(hashed_bundle) 68 | full_path = File.join Rails.root, 'public', hashed_bundle 69 | 70 | File.open full_path, 'rb' 71 | end 72 | end 73 | end -------------------------------------------------------------------------------- /rails-react-ssr.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "rails_react_ssr/version" 3 | 4 | Gem::Specification.new do |spec| 5 | spec.name = "rails-react-ssr" 6 | spec.version = RailsReactSSR::VERSION 7 | spec.authors = ["James Fawks"] 8 | spec.email = ["jefawks3@gmail.com"] 9 | 10 | spec.summary = 'Light weight React SSR (Server Side Rendering) integration for Ruby on Rails, Webpacker and NodeJS' 11 | spec.homepage = 'https://github.com/jefawks3/rails-react-ssr' 12 | spec.license = "MIT" 13 | 14 | spec.required_ruby_version = ">= 2.2.0" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = 'https://github.com/jefawks3/rails-react-ssr' 18 | spec.metadata["changelog_uri"] = 'https://github.com/jefawks3/rails-react-ssr/releases' 19 | 20 | spec.files = Dir[ 21 | 'lib/**/*', 22 | 'README.md', 23 | 'LICENSE', 24 | ] 25 | 26 | spec.test_files = Dir['test/**/*'] 27 | 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency "webpacker", ">= 4.0.2" 31 | spec.add_dependency "rails", ">= 5.2.1" 32 | 33 | spec.add_development_dependency "bundler", ">= 2.2.33" 34 | spec.add_development_dependency "rake", ">= 12.3.3" 35 | spec.add_development_dependency "minitest", "~> 5.15" 36 | spec.add_development_dependency "minitest-ci", "~> 3.4" 37 | spec.add_development_dependency "byebug", "~> 11.1" 38 | end 39 | -------------------------------------------------------------------------------- /test/server_runner_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class RailsReactSSR::ServerRunnerTest < RailsReactSSR::Test 4 | def setup 5 | tmp_dir = File.expand_path 'tmp' 6 | Dir.mkdir tmp_dir unless Dir.exist? tmp_dir 7 | end 8 | 9 | def teardown 10 | # Do nothing 11 | end 12 | 13 | def test_application_temp_output 14 | skip "This is failing on the CI server. Outputs are different than running locally." 15 | 16 | tempFile = File.expand_path 'tmp/output.js' 17 | File.unlink tempFile if File.exist? tempFile 18 | RailsReactSSR::ServerRunner.exec! 'application.js', outputTemp: tempFile 19 | 20 | assert_equal File.read(tempFile), <<-OUTPUT 21 | const stdout = console.log; 22 | const stderr = console.error; 23 | 24 | const recordedLogs = []; 25 | 26 | ['log', 'info', 'debug', 'warn', 'error'].forEach(level => { 27 | console[level] = (...args) => { 28 | recordedLogs.push({ level: level, args: args }); 29 | } 30 | }); 31 | 32 | 33 | const serverProps = {}; 34 | 35 | console.log('Hello World from Webpacker'); 36 | 37 | stdout('Hello from the server'); 38 | OUTPUT 39 | end 40 | 41 | def test_application_output 42 | output = RailsReactSSR::ServerRunner.exec! 'application.js' 43 | 44 | assert_equal output, <<-OUTPUT 45 | Hello from the server 46 | OUTPUT 47 | end 48 | end -------------------------------------------------------------------------------- /test/test_app/Rakefile: -------------------------------------------------------------------------------- 1 | require_relative "config/application" 2 | 3 | Rails.application.load_tasks 4 | -------------------------------------------------------------------------------- /test/test_app/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | console.log('Hello World from Webpacker'); 2 | 3 | stdout('Hello from the server'); 4 | -------------------------------------------------------------------------------- /test/test_app/bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= ENV["RAILS_ENV"] 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | Webpacker::WebpackRunner.run(ARGV) 15 | -------------------------------------------------------------------------------- /test/test_app/bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= ENV["RAILS_ENV"] 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | Webpacker::DevServerRunner.run(ARGV) 15 | -------------------------------------------------------------------------------- /test/test_app/config.ru: -------------------------------------------------------------------------------- 1 | # This file allows the `Rails.root` to be correctly determined. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /test/test_app/config/application.rb: -------------------------------------------------------------------------------- 1 | require "action_controller/railtie" 2 | require "action_view/railtie" 3 | require "webpacker" 4 | 5 | module TestApp 6 | class Application < ::Rails::Application 7 | config.secret_key_base = "abcdef" 8 | config.eager_load = true 9 | config.webpacker.check_yarn_integrity = false 10 | config.active_support.test_order = :sorted 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/test_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | require_relative "application" 2 | 3 | Rails.backtrace_cleaner.remove_silencers! 4 | Rails.application.initialize! 5 | -------------------------------------------------------------------------------- /test/test_app/config/webpack/development.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefawks3/rails-react-ssr/c3095cf6a48449cc3d53091a7a6f4da2bd9e49a6/test/test_app/config/webpack/development.js -------------------------------------------------------------------------------- /test/test_app/config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/javascript 5 | source_entry_path: packs 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | webpack_compile_output: false 10 | 11 | # Additional paths webpack should lookup modules 12 | # ['app/assets', 'engine/foo/app/assets'] 13 | resolved_paths: 14 | - app/assets 15 | - /etc/yarn 16 | 17 | # Reload manifest.json on all requests so we reload latest compiled packs 18 | cache_manifest: false 19 | 20 | # Extract and emit a css file 21 | extract_css: false 22 | 23 | static_assets_extensions: 24 | - .jpg 25 | - .jpeg 26 | - .png 27 | - .gif 28 | - .tiff 29 | - .ico 30 | - .svg 31 | 32 | extensions: 33 | - .mjs 34 | - .js 35 | - .sass 36 | - .scss 37 | - .css 38 | - .module.sass 39 | - .module.scss 40 | - .module.css 41 | - .png 42 | - .svg 43 | - .gif 44 | - .jpeg 45 | - .jpg 46 | 47 | development: 48 | <<: *default 49 | compile: true 50 | 51 | # Reference: https://webpack.js.org/configuration/dev-server/ 52 | dev_server: 53 | https: false 54 | host: localhost 55 | port: 3035 56 | public: localhost:3035 57 | hmr: false 58 | # Inline should be set to true if using HMR 59 | inline: true 60 | overlay: true 61 | disable_host_check: true 62 | use_local_ip: false 63 | pretty: false 64 | 65 | test: 66 | <<: *default 67 | compile: true 68 | 69 | # Compile test packs to a separate directory 70 | public_output_path: packs-test 71 | 72 | production: 73 | <<: *default 74 | 75 | # Production depends on precompilation of packs prior to booting for performance. 76 | compile: false 77 | 78 | # Extract and emit a css file 79 | extract_css: true 80 | 81 | # Cache manifest.json for performance 82 | cache_manifest: true 83 | 84 | staging: 85 | <<: *default 86 | 87 | # Production depends on precompilation of packs prior to booting for performance. 88 | compile: false 89 | 90 | # Extract and emit a css file 91 | extract_css: true 92 | 93 | # Cache manifest.json for performance 94 | cache_manifest: true 95 | 96 | # Compile staging packs to a separate directory 97 | public_output_path: packs-staging 98 | -------------------------------------------------------------------------------- /test/test_app/config/webpacker_public_root.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | public_root_path: ../public 5 | 6 | development: 7 | <<: *default 8 | compile: true 9 | 10 | test: 11 | <<: *default 12 | compile: true 13 | public_output_path: packs-test 14 | 15 | production: 16 | <<: *default 17 | compile: false 18 | extract_css: true 19 | cache_manifest: true 20 | -------------------------------------------------------------------------------- /test/test_app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_app", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "left-pad": "^1.2.0" 9 | }, 10 | "devDependencies": { 11 | "right-pad": "^1.0.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/test_app/public/packs/application-k344a6d59eef8632c9d1.js: -------------------------------------------------------------------------------- 1 | console.log('Hello World from Webpacker'); 2 | 3 | stdout('Hello from the server'); 4 | -------------------------------------------------------------------------------- /test/test_app/public/packs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap.css": "/packs/bootstrap-c38deda30895059837cf.css", 3 | "application.css": "/packs/application-dd6b1cd38bfa093df600.css", 4 | "bootstrap.js": "/packs/bootstrap-300631c4f0e0f9c865bc.js", 5 | "application.js": "/packs/application-k344a6d59eef8632c9d1.js", 6 | "application.png": "/packs/application-k344a6d59eef8632c9d1.png", 7 | "fonts/fa-regular-400.woff2": "/packs/fonts/fa-regular-400-944fb546bd7018b07190a32244f67dc9.woff2", 8 | "media/images/image.jpg": "/packs/media/images/image-c38deda30895059837cf.jpg", 9 | "media/images/nested/image.jpg": "/packs/media/images/nested/image-c38deda30895059837cf.jpg", 10 | "media/images/mb-icon.png": "/packs/media/images/mb-icon-c38deda30895059837cf.png", 11 | "media/images/nested/mb-icon.png": "/packs/media/images/nested/mb-icon-c38deda30895059837cf.png", 12 | "entrypoints": { 13 | "application": { 14 | "js": [ 15 | "/packs/vendors~application~bootstrap-c20632e7baf2c81200d3.chunk.js", 16 | "/packs/vendors~application-e55f2aae30c07fb6d82a.chunk.js", 17 | "/packs/application-k344a6d59eef8632c9d1.js" 18 | ], 19 | "css": [ 20 | "/packs/1-c20632e7baf2c81200d3.chunk.css", 21 | "/packs/application-k344a6d59eef8632c9d1.chunk.css" 22 | ] 23 | }, 24 | "hello_stimulus": { 25 | "css": [ 26 | "/packs/1-c20632e7baf2c81200d3.chunk.css", 27 | "/packs/hello_stimulus-k344a6d59eef8632c9d1.chunk.css" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/test_app/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | left-pad@^1.2.0: 6 | version "1.2.0" 7 | resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.0.tgz#d30a73c6b8201d8f7d8e7956ba9616087a68e0ee" 8 | 9 | right-pad@^1.0.1: 10 | version "1.0.1" 11 | resolved "https://registry.yarnpkg.com/right-pad/-/right-pad-1.0.1.tgz#8ca08c2cbb5b55e74dafa96bf7fd1a27d568c8d0" 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | 3 | require "rails" 4 | require "rails/test_help" 5 | require "byebug" 6 | require 'rails-react-ssr' 7 | 8 | require_relative "test_app/config/environment" 9 | 10 | Rails.env = "production" 11 | 12 | Rails.logger = Logger.new(STDOUT) 13 | Rails.logger.level = Logger::DEBUG 14 | 15 | 16 | Webpacker.instance = ::Webpacker::Instance.new 17 | 18 | class RailsReactSSR::Test < Minitest::Test 19 | private 20 | def reloaded_config 21 | Webpacker.instance.instance_variable_set(:@env, nil) 22 | Webpacker.instance.instance_variable_set(:@config, nil) 23 | Webpacker.instance.instance_variable_set(:@dev_server, nil) 24 | Webpacker.env 25 | Webpacker.config 26 | Webpacker.dev_server 27 | end 28 | 29 | def with_rails_env(env) 30 | original = Rails.env 31 | Rails.env = ActiveSupport::StringInquirer.new(env) 32 | reloaded_config 33 | yield 34 | ensure 35 | Rails.env = ActiveSupport::StringInquirer.new(original) 36 | reloaded_config 37 | end 38 | end -------------------------------------------------------------------------------- /test/webpacker_utils_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'webpacker/dev_server_runner' 3 | 4 | class RailsReactSSR::WebpackerUtilsTest < RailsReactSSR::Test 5 | def test_bundle_not_found! 6 | error = assert_raises RailsReactSSR::MissingBundleError do 7 | RailsReactSSR::WebpackerUtils.hashed_bundle_name! 'missing.js' 8 | end 9 | 10 | assert_match 'missing.js', error.bundle 11 | assert_match "The ReactJS package 'missing.js' is missing from the manifest.json file.", error.message 12 | end 13 | 14 | def test_bundle_found! 15 | assert_equal RailsReactSSR::WebpackerUtils.hashed_bundle_name!('application.js'), 16 | '/packs/application-k344a6d59eef8632c9d1.js' 17 | end 18 | 19 | def test_open_local_file 20 | io = RailsReactSSR::WebpackerUtils.open_bundle 'application.js' 21 | 22 | refute Webpacker.dev_server.running? 23 | 24 | assert_equal io.read, raw_application_js 25 | end 26 | 27 | def test_open_remote_file 28 | # TODO Run dev server during tests to make sure remote file is accessible 29 | skip 'Need to find a way to run the dev server during the tests' 30 | end 31 | 32 | def test_build_remote_uri 33 | with_rails_env 'development' do 34 | hashed_bundle = '/packs/application-k344a6d59eef8632c9d1.js' 35 | uri = RailsReactSSR::WebpackerUtils.send :dev_bundle_uri, hashed_bundle 36 | 37 | assert_equal uri, 'http://localhost:3035/packs/application-k344a6d59eef8632c9d1.js' 38 | end 39 | end 40 | 41 | private 42 | 43 | def raw_application_js 44 | <<-AppplicaitonJS 45 | console.log('Hello World from Webpacker'); 46 | 47 | stdout('Hello from the server'); 48 | AppplicaitonJS 49 | end 50 | end --------------------------------------------------------------------------------