diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ec28a59e..488894994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -244,6 +244,16 @@ Run `docker-compose build ci` to build the CI container. Run `docker-compose run Run `docker-compose build tests` to build the tests container. Run `docker-compose run tests` to start all RSpec tests. +### Server-side-rendering performance benchmarking + +See a simple example of benchmarking different ExecJS runtimes (NodeJS, [MiniRacer](https://github.com/rubyjs/mini_racer), [Alaska](https://github.com/mavenlink/alaska)) in `spec/dummy/bin/benchmark`. + +After setting up the dummy app (`spec/dummy`), run: +```bash +cd spec/dummy +bin/benchmark +``` + # Advice for Project Maintainers and Contributors What do project maintainers do? What sort of work is involved? [sstephenson](https://github.com/sstephenson) wrote in the [turbolinks](https://github.com/turbolinks/turbolinks) repo: diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index d1cc00f04..d252a2f90 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -2,7 +2,7 @@ gem "shakapacker", "6.5.1" gem "bootsnap", require: false -gem "rails", "~> 7.0" +gem "rails", "~> 7.0", ">= 7.0.1" gem "sqlite3" gem "sass-rails", "~> 6.0" gem "uglifier" @@ -24,7 +24,7 @@ gem "amazing_print" group :development, :test do gem "listen" - gem "pry" + gem "pry", ">= 0.14.2" gem "pry-byebug" gem "pry-doc" gem "pry-rails" @@ -34,6 +34,10 @@ group :development, :test do gem "rubocop-rspec", require: false gem "scss_lint", require: false gem "spring", "~> 4.0" + + # Example ExecJS runtimes + gem "mini_racer", require: false + gem "alaska", require: false end group :test do diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock index 89dada5f1..f3ab225e9 100644 --- a/spec/dummy/Gemfile.lock +++ b/spec/dummy/Gemfile.lock @@ -11,67 +11,75 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (7.0.0) - actionpack (= 7.0.0) - activesupport (= 7.0.0) + actioncable (7.0.6) + actionpack (= 7.0.6) + activesupport (= 7.0.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.0) - actionpack (= 7.0.0) - activejob (= 7.0.0) - activerecord (= 7.0.0) - activestorage (= 7.0.0) - activesupport (= 7.0.0) + actionmailbox (7.0.6) + actionpack (= 7.0.6) + activejob (= 7.0.6) + activerecord (= 7.0.6) + activestorage (= 7.0.6) + activesupport (= 7.0.6) mail (>= 2.7.1) - actionmailer (7.0.0) - actionpack (= 7.0.0) - actionview (= 7.0.0) - activejob (= 7.0.0) - activesupport (= 7.0.0) + net-imap + net-pop + net-smtp + actionmailer (7.0.6) + actionpack (= 7.0.6) + actionview (= 7.0.6) + activejob (= 7.0.6) + activesupport (= 7.0.6) mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.0) - actionview (= 7.0.0) - activesupport (= 7.0.0) - rack (~> 2.0, >= 2.2.0) + actionpack (7.0.6) + actionview (= 7.0.6) + activesupport (= 7.0.6) + rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.0) - actionpack (= 7.0.0) - activerecord (= 7.0.0) - activestorage (= 7.0.0) - activesupport (= 7.0.0) + actiontext (7.0.6) + actionpack (= 7.0.6) + activerecord (= 7.0.6) + activestorage (= 7.0.6) + activesupport (= 7.0.6) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.0) - activesupport (= 7.0.0) + actionview (7.0.6) + activesupport (= 7.0.6) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.0) - activesupport (= 7.0.0) + activejob (7.0.6) + activesupport (= 7.0.6) globalid (>= 0.3.6) - activemodel (7.0.0) - activesupport (= 7.0.0) - activerecord (7.0.0) - activemodel (= 7.0.0) - activesupport (= 7.0.0) - activestorage (7.0.0) - actionpack (= 7.0.0) - activejob (= 7.0.0) - activerecord (= 7.0.0) - activesupport (= 7.0.0) + activemodel (7.0.6) + activesupport (= 7.0.6) + activerecord (7.0.6) + activemodel (= 7.0.6) + activesupport (= 7.0.6) + activestorage (7.0.6) + actionpack (= 7.0.6) + activejob (= 7.0.6) + activerecord (= 7.0.6) + activesupport (= 7.0.6) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.0) + activesupport (7.0.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) + alaska (1.2.2) + execjs (~> 2.7) amazing_print (1.2.2) ast (2.4.2) bootsnap (1.7.2) @@ -99,6 +107,7 @@ GEM thor (>= 0.19.4, < 2.0) tins (~> 1.6) crass (1.0.6) + date (3.3.3) diff-lcs (1.4.4) docile (1.3.5) equivalent-xml (0.6.0) @@ -109,7 +118,7 @@ GEM generator_spec (0.9.4) activesupport (>= 3.0.0) railties (>= 3.0.0) - globalid (1.0.0) + globalid (1.1.0) activesupport (>= 5.0) i18n (1.8.11) concurrent-ruby (~> 1.0) @@ -123,33 +132,46 @@ GEM json (2.5.1) launchy (2.5.0) addressable (~> 2.7) + libv8-node (18.16.0.0-arm64-darwin) listen (3.4.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) loofah (2.13.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.7.1) + mail (2.8.1) mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp marcel (1.0.2) method_source (1.0.0) mini_mime (1.1.2) - mini_portile2 (2.8.1) + mini_racer (0.8.0) + libv8-node (~> 18.16.0.0) minitest (5.15.0) msgpack (1.4.2) + net-imap (0.3.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.1) + timeout + net-smtp (0.3.3) + net-protocol nio4r (2.5.8) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.14.3-arm64-darwin) racc (~> 1.4) parallel (1.20.1) parser (3.0.1.1) ast (~> 2.4.1) - pry (0.13.1) + pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.9.0) + pry-byebug (3.10.1) byebug (~> 11.0) - pry (~> 0.13.0) + pry (>= 0.13, < 0.15) pry-doc (1.1.0) pry (~> 0.11) yard (~> 0.9.11) @@ -162,33 +184,33 @@ GEM puma (5.2.2) nio4r (~> 2.0) racc (1.6.2) - rack (2.2.3) + rack (2.2.7) rack-proxy (0.7.4) rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (7.0.0) - actioncable (= 7.0.0) - actionmailbox (= 7.0.0) - actionmailer (= 7.0.0) - actionpack (= 7.0.0) - actiontext (= 7.0.0) - actionview (= 7.0.0) - activejob (= 7.0.0) - activemodel (= 7.0.0) - activerecord (= 7.0.0) - activestorage (= 7.0.0) - activesupport (= 7.0.0) + rails (7.0.6) + actioncable (= 7.0.6) + actionmailbox (= 7.0.6) + actionmailer (= 7.0.6) + actionpack (= 7.0.6) + actiontext (= 7.0.6) + actionview (= 7.0.6) + activejob (= 7.0.6) + activemodel (= 7.0.6) + activerecord (= 7.0.6) + activestorage (= 7.0.6) + activesupport (= 7.0.6) bundler (>= 1.15.0) - railties (= 7.0.0) + railties (= 7.0.6) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) - railties (7.0.0) - actionpack (= 7.0.0) - activesupport (= 7.0.0) + railties (7.0.6) + actionpack (= 7.0.6) + activesupport (= 7.0.6) method_source rake (>= 12.2) thor (~> 1.0) @@ -289,6 +311,7 @@ GEM tins (~> 1.0) thor (1.1.0) tilt (2.0.10) + timeout (0.4.0) tins (1.28.0) sync turbolinks (5.2.1) @@ -313,9 +336,10 @@ GEM zeitwerk (2.5.2) PLATFORMS - ruby + arm64-darwin-22 DEPENDENCIES + alaska amazing_print bootsnap bundler (= 2.4.9) @@ -328,13 +352,14 @@ DEPENDENCIES jquery-rails launchy listen - pry + mini_racer + pry (>= 0.14.2) pry-byebug pry-doc pry-rails pry-rescue puma (~> 5.0) - rails (~> 7.0) + rails (~> 7.0, >= 7.0.1) react_on_rails! rspec-rails rspec-retry diff --git a/spec/dummy/bin/benchmark b/spec/dummy/bin/benchmark new file mode 100755 index 000000000..6ef4bfc15 --- /dev/null +++ b/spec/dummy/bin/benchmark @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# +# A simple example of how to benchmark the dummy app. +# + +set -euo pipefail + +shutdown_server() { + if [ -f tmp/pids/server.pid ]; then + kill $(cat tmp/pids/server.pid) + rm -f tmp/pids/server.pid + fi +} + +hit_backend() { + local pathname="$1" + curl "http://localhost:3000$pathname" \ + --max-time 3 \ + --silent \ + --no-progress-meter \ + --fail \ + -w %{time_total} \ + -o /dev/null +} + +run_benchmarks() { + local runtime="$1" + local pathname="$2" + + echo "Benchmarking with $runtime..." + + # start dummy app server in background + MANUAL_EXECJS_RUNTIME="$runtime" \ + RAILS_ENV=production \ + bundle exec rails s -p 3000 & + + sleep 3 # wait for server to start + + echo "Warmup: $(hit_backend $pathname)" + + durations=() + for i in {1..10}; do + dur=$(hit_backend $pathname) + durations+=("$dur") + echo "Request $i: ${dur}s" + done + + echo "$runtime average: $(echo "${durations[*]}" | awk '{ total += $1 } END { print total/NR }')s" + + shutdown_server +} + +# If you modified `node_package` files, first follow the +# instructions in CONTRIBUTING.md to setup `yalc`. + +# Build server-rendering assets: +# yarn build:rescript # if needed +rm -rf public/webpack +RAILS_ENV=production NODE_ENV=production bin/webpacker + +# kill server on exit +trap shutdown_server EXIT + +# kill any existing server +shutdown_server + +# remove any existing logs +rm -f log/*.log + +echo "Benchmarking server-side rendering..." + +# run_benchmarks Node /render_js # FIXME: Node runtime is broken with this setup, it stalls infinitely. +run_benchmarks MiniRacer /render_js +run_benchmarks Alaska /render_js + +# FIXME: all others SSR endpoints are broken with this setup +# e.g. Hitting /server_side_hello_world gives error: "ActionView::Template::Error (Shakapacker can't find generated/HelloWorld.js in manifest.json" + +echo "Benchmarking complete!" diff --git a/spec/dummy/client/app/miniracerPolyfills.js b/spec/dummy/client/app/miniracerPolyfills.js new file mode 100644 index 000000000..0f2f124f7 --- /dev/null +++ b/spec/dummy/client/app/miniracerPolyfills.js @@ -0,0 +1,18 @@ +// +// polyfills for mini_racer v8 runtime (which has less features than node.js) +// + +// `URL` constructor +import 'core-js/actual/url'; +// `URLSearchParams` constructor +import 'core-js/actual/url-search-params'; + +// polyfill TextEncoder & TextDecoder onto `util` b/c `node-util` polyfill doesn't include them +// https://github.com/browserify/node-util/issues/46 +import util from 'util'; +import 'fast-text-encoding'; + +Object.assign(util, { TextDecoder, TextEncoder }); + +// some packages (e.g. `react-dnd`) expect `global` to be available during SSR +globalThis.global = globalThis; diff --git a/spec/dummy/config/initializers/execjs.rb b/spec/dummy/config/initializers/execjs.rb new file mode 100644 index 000000000..e7953c7f9 --- /dev/null +++ b/spec/dummy/config/initializers/execjs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +case ENV["MANUAL_EXECJS_RUNTIME"] +when "Node" + ExecJS.runtime = ExecJS::Runtimes::Node +when "Alaska" + require "alaska/runtime" + ExecJS.runtime = Alaska::Runtime.new +when "MiniRacer" + require "mini_racer" + ExecJS.runtime = ExecJS::Runtimes::MiniRacer +end diff --git a/spec/dummy/config/webpack/serverWebpackConfig.js b/spec/dummy/config/webpack/serverWebpackConfig.js index b64a616a6..a09d06a69 100644 --- a/spec/dummy/config/webpack/serverWebpackConfig.js +++ b/spec/dummy/config/webpack/serverWebpackConfig.js @@ -1,4 +1,5 @@ const { merge, config } = require('shakapacker'); +const path = require('path'); const commonWebpackConfig = require('./commonWebpackConfig'); const webpack = require('webpack'); @@ -8,6 +9,7 @@ const configureServer = () => { // toWebpackConfig() is a mutable GLOBAL. Thus any changes, like modifying the // entry value will result in changing the client config! // Using webpack-merge into an empty object avoids this issue. + /** @type {webpack.Configuration} */ const serverWebpackConfig = commonWebpackConfig(); // We just want the single server bundle entry @@ -109,6 +111,31 @@ const configureServer = () => { // If using the React on Rails Pro node server renderer, uncomment the next line // serverWebpackConfig.target = 'node' + // MiniRacer specific config + if (process.env.EXECJS_RUNTIME === 'MiniRacer') { + // disable nodejs target + serverWebpackConfig.target = false; + + serverWebpackConfig.output.chunkFormat = 'commonjs'; + + // add `process` polyfill + serverWebpackConfig.plugins.push( + new webpack.ProvidePlugin({ + process: 'process/browser', + }), + ); + + // add `stream` polyfill + serverWebpackConfig.resolve.fallback ||= {}; + serverWebpackConfig.resolve.fallback.stream = require.resolve('stream-browserify'); + + // add polyfills: TextEncoder/TextDecoder, URLSearchParams, global + serverWebpackConfig.entry['server-bundle'] = [ + path.resolve(process.cwd(), 'client/app/miniracerPolyfills.js'), + serverWebpackConfig.entry['server-bundle'], + ]; + } + return serverWebpackConfig; }; diff --git a/spec/dummy/package.json b/spec/dummy/package.json index 2ba265a0d..99ec75ec8 100644 --- a/spec/dummy/package.json +++ b/spec/dummy/package.json @@ -24,6 +24,7 @@ "css-minimizer-webpack-plugin": "^3.1.3", "eslint-plugin-prettier": "^3.1.0", "expose-loader": "^1.0.3", + "fast-text-encoding": "^1.0.6", "file-loader": "^6.2.0", "history": "^4.6.3", "imports-loader": "^1.2.0", @@ -51,6 +52,7 @@ "sass-loader": "^12.3.0", "sass-resources-loader": "^2.1.0", "shakapacker": "6.5.1", + "stream-browserify": "^3.0.0", "style-loader": "^3.3.1", "terser-webpack-plugin": "5.3.1", "url-loader": "^4.0.0", diff --git a/spec/dummy/yarn.lock b/spec/dummy/yarn.lock index 03d4ee609..e4edc2276 100644 --- a/spec/dummy/yarn.lock +++ b/spec/dummy/yarn.lock @@ -4259,6 +4259,11 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-text-encoding@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" + integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== + fastest-levenshtein@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2" @@ -4761,7 +4766,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6482,7 +6487,7 @@ react-is@^18.0.0: integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== "react-on-rails@file:.yalc/react-on-rails": - version "12.0.5-beta.0" + version "13.3.5" dependencies: "@babel/runtime-corejs3" "^7.12.5" concurrently "^5.1.0" @@ -6583,6 +6588,15 @@ readable-stream@^3.0.6: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^3.5.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -7351,6 +7365,14 @@ stream-browserify@^2.0.1: inherits "~2.0.1" readable-stream "^2.0.2" +stream-browserify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + stream-combiner@~0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14"