shakacode/react_on_rails

View on GitHub
lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require "open-uri"
require "execjs"

module ReactOnRails
  module ServerRenderingPool
    # rubocop:disable Metrics/ClassLength
    class RubyEmbeddedJavaScript
      class << self
        def reset_pool
          options = {
            size: ReactOnRails.configuration.server_renderer_pool_size,
            timeout: ReactOnRails.configuration.server_renderer_timeout
          }
          @js_context_pool = ConnectionPool.new(options) { create_js_context }
        end

        def reset_pool_if_server_bundle_was_modified
          return unless ReactOnRails.configuration.development_mode

          if ReactOnRails::Utils.server_bundle_path_is_http?
            return if @server_bundle_url == ReactOnRails::Utils.server_bundle_js_file_path

            @server_bundle_url = ReactOnRails::Utils.server_bundle_js_file_path
          else
            file_mtime = File.mtime(ReactOnRails::Utils.server_bundle_js_file_path)
            @server_bundle_timestamp ||= file_mtime
            return if @server_bundle_timestamp == file_mtime

            @server_bundle_timestamp = file_mtime
          end
          ReactOnRails::ServerRenderingPool.reset_pool
        end

        # js_code: JavaScript expression that returns a string.
        # render_options: lib/react_on_rails/react_component/render_options.rb
        # Using these options:
        #    trace: saves the executed JS to a file, used in development
        #    logging_on_server: put on server logs, not just in browser console
        #
        # Returns a Hash:
        #   html: string of HTML for direct insertion on the page by evaluating js_code
        #   consoleReplayScript: script for replaying console
        #   hasErrors: true if server rendering errors
        # Note, js_code does not have to be based on React.
        # js_code MUST RETURN json stringify Object
        # Calling code will probably call 'html_safe' on return value before rendering to the view.
        # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
        def exec_server_render_js(js_code, render_options, js_evaluator = nil)
          js_evaluator ||= self
          if render_options.trace
            @file_index ||= 1
            trace_js_code_used("Evaluating code to server render.", js_code,
                               "tmp/server-generated-#{@file_index % 10}.js")
            @file_index += 1
          end
          begin
            json_string = js_evaluator.eval_js(js_code, render_options)
          rescue StandardError => err
            msg = <<~MSG
              Error evaluating server bundle. Check your webpack configuration.
              ===============================================================
              Caught error:
              #{err.message}
              ===============================================================
            MSG

            if err.message.include?("ReferenceError: self is not defined")
              msg << "\nError indicates that you may have code-splitting incorrectly enabled.\n"
            end
            raise ReactOnRails::Error, msg, err.backtrace
          end
          result = nil
          begin
            result = JSON.parse(json_string)
          rescue JSON::ParserError => e
            raise ReactOnRails::JsonParseError.new(parse_error: e, json: json_string)
          end

          if render_options.logging_on_server
            console_script = result["consoleReplayScript"]
            console_script_lines = console_script.split("\n")
            console_script_lines = console_script_lines[2..-2]
            re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?<msg>.*)"\]\);/
            console_script_lines&.each do |line|
              match = re.match(line)
              Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match
            end
          end
          result
        end
        # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity

        def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false)
          return unless ReactOnRails.configuration.trace || force

          # Set to anything to print generated code.
          File.write(file_name, js_code)
          msg = <<-MSG.strip_heredoc
            #{'Z' * 80}
            [react_on_rails] #{msg}
            JavaScript code used: #{file_name}
            #{'Z' * 80}
          MSG
          if force
            Rails.logger.error(msg)
          else
            Rails.logger.info(msg)
          end
        end

        def eval_js(js_code, _render_options)
          @js_context_pool.with do |js_context|
            js_context.eval(js_code)
          end
        end

        def read_bundle_js_code
          server_js_file = ReactOnRails::Utils.server_bundle_js_file_path
          if ReactOnRails::Utils.server_bundle_path_is_http?
            file_url_to_string(server_js_file)
          else
            File.read(server_js_file)
          end
        rescue StandardError => e
          msg = "You specified server rendering JS file: #{server_js_file}, but it cannot be " \
                "read. You may set the server_bundle_js_file in your configuration to be \"\" to " \
                "avoid this warning.\nError is: #{e}"
          raise ReactOnRails::Error, msg
        end

        def create_js_context
          return if ReactOnRails.configuration.server_bundle_js_file.blank?

          bundle_js_code = read_bundle_js_code
          base_js_code = <<~JS
            #{console_polyfill}
            #{execjs_timer_polyfills}
            #{bundle_js_code};
          JS

          file_name = "tmp/base_js_code.js"
          begin
            if ReactOnRails.configuration.trace
              Rails.logger.info do
                "[react_on_rails] Created JavaScript context with file " \
                  "#{ReactOnRails::Utils.server_bundle_js_file_path}"
              end
            end
            ExecJS.compile(base_js_code)
          rescue StandardError => e
            msg = "ERROR when compiling base_js_code! " \
                  "See file #{file_name} to " \
                  "correlate line numbers of error. Error is\n\n#{e.message}" \
                  "\n\n#{e.backtrace.join("\n")}"
            Rails.logger.error(msg)
            trace_js_code_used("Error when compiling JavaScript code for the context.", base_js_code,
                               file_name, force: true)
            raise e
          end
        end

        def execjs_timer_polyfills
          <<~JS
            function getStackTrace () {
              var stack;
              try {
                throw new Error('');
              }
              catch (error) {
                stack = error.stack || '';
              }
              stack = stack.split('\\n').map(function (line) { return line.trim(); });
              return stack.splice(stack[0] == 'Error' ? 2 : 1);
            }

            function setInterval() {
              #{undefined_for_exec_js_logging('setInterval')}
            }

            function setTimeout() {
              #{undefined_for_exec_js_logging('setTimeout')}
            }

            function clearTimeout() {
              #{undefined_for_exec_js_logging('clearTimeout')}
            }
          JS
        end

        def undefined_for_exec_js_logging(function_name)
          if ReactOnRails.configuration.trace
            "console.error('[React on Rails Rendering] #{function_name} is not defined for server rendering.');\n  " \
              "console.error(getStackTrace().join('\\n'));"
          else
            ""
          end
        end

        # Reimplement console methods for replaying on the client
        # Save a handle to the original console if needed.
        def console_polyfill
          <<~JS
            var debugConsole = console;
            var console = { history: [] };
            ['error', 'log', 'info', 'warn'].forEach(function (level) {
              console[level] = function () {
                var argArray = Array.prototype.slice.call(arguments);
                if (argArray.length > 0) {
                  argArray[0] = '[SERVER] ' + argArray[0];
                }
                console.history.push({level: level, arguments: argArray});
              };
            });
          JS
        end

        if defined?(ScoutApm)
          include ScoutApm::Tracer
          instrument_method :exec_server_render_js, type: "ReactOnRails", name: "ExecJs React Server Rendering"
        end

        private

        def file_url_to_string(url)
          response = Net::HTTP.get_response(URI.parse(url))
          content_type_header = response["content-type"]
          match = content_type_header.match(/\A.*; charset=(?<encoding>.*)\z/)
          encoding_type = match[:encoding]
          response.body.force_encoding(encoding_type)
        rescue StandardError => e
          msg = "file_url_to_string #{url} failed\nError is: #{e}"
          raise ReactOnRails::Error, msg
        end
      end
      # rubocop:enable Metrics/ClassLength
    end
  end
end