From 7c02a5954f83ed4c21d7704591687475ca515d0a Mon Sep 17 00:00:00 2001 From: Mars Hall Date: Sun, 16 Oct 2016 17:41:28 -0700 Subject: [PATCH] Modular runtime env vars (#3) * Set-up env var replacement in generated index file * Render Mustaches in the Webpack bundle * Fix bundle name * Generate composite `REACT_APP_VARS_AS_JSON` env var at runtime * Add runtime config to slug * Fix compile path * Escape quotes so bundle replacement works * Set utf-8 for JSON encoding function, because old-skool Ruby 1.9 * Switch to Ruby script for env var to JSON conversion. * Fix runtime paths * Set utf-8 for JSON encoding function, because old-skool Ruby 1.9 * Fix for env values with unknown encoding. * Actually write the runtime bundle * Another level of escapes. * Fix for space char breaking sed expression * Escape forward slashes too; they break sed expression * Escape ampersand too; they break sed expression * Replace `sed` JSON injection with pure Ruby * Fix pure Ruby injector command with correct args * Fix file path to JS bundle * More escapes for JSON values * Fix so injected values just work without further escaping by developer. * TravisCI * Use rake to execute tests (for TravisCI) * Fix missing dependency * Simplification fix for double quote escaping. * Triple backslash escape for double-quote in JSON value. * Revise JSON escaping for control chars * Fail gracefully for old CRA versions * Improve "Injecting runtime" log message --- .profile.d/inject_react_app_env.sh | 24 ++++++ .rspec | 3 + .travis.yml | 3 + Gemfile | 9 +++ Gemfile.lock | 31 +++++++ README.md | 9 +++ Rakefile | 9 +++ bin/compile | 10 +++ lib/injectable_env.rb | 51 ++++++++++++ spec/injectable_env_spec.rb | 126 +++++++++++++++++++++++++++++ spec/spec_helper.rb | 103 +++++++++++++++++++++++ 11 files changed, 378 insertions(+) create mode 100755 .profile.d/inject_react_app_env.sh create mode 100644 .rspec create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Rakefile create mode 100644 lib/injectable_env.rb create mode 100644 spec/injectable_env_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.profile.d/inject_react_app_env.sh b/.profile.d/inject_react_app_env.sh new file mode 100755 index 0000000..a1b5778 --- /dev/null +++ b/.profile.d/inject_react_app_env.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Fail immediately on non-zero exit code. +set -e +# Debug, echo every command +#set -x + +# Each bundle is generated with a unique hash name +# to bust browser cache. +js_bundle=/app/build/static/js/main.*.js + +if [ -f $js_bundle ] +then + + # Get exact filename. + js_bundle_filename=`ls $js_bundle` + + echo "Injecting runtime env into $js_bundle_filename (from .profile.d/inject_react_app_env.sh)" + + # Render runtime env vars into bundle. + ruby -E utf-8:utf-8 \ + -r /app/.heroku/create-react-app/injectable_env.rb \ + -e "InjectableEnv.replace('$js_bundle_filename')" +fi diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..b83d9b7 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--format documentation +--require spec_helper diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0d7ba0e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: ruby +rvm: + - 1.9.3 \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..bbeb8dd --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# encoding: utf-8 +# frozen_string_literal: true +source "https://rubygems.org" +ruby '1.9.3' + +group :test do + gem 'rake' + gem 'rspec' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..7b09470 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,31 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.2.5) + rake (11.3.0) + rspec (3.5.0) + rspec-core (~> 3.5.0) + rspec-expectations (~> 3.5.0) + rspec-mocks (~> 3.5.0) + rspec-core (3.5.4) + rspec-support (~> 3.5.0) + rspec-expectations (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-mocks (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-support (3.5.0) + +PLATFORMS + ruby + +DEPENDENCIES + rake + rspec + +RUBY VERSION + ruby 1.9.3p551 + +BUNDLED WITH + 1.13.4 diff --git a/README.md b/README.md index 909ffeb..77265cf 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,12 @@ Inner layer of Heroku Buildpack for create-react-app ==================================================== See: [create-react-app-buildpack](https://github.com/mars/create-react-app-buildpack) + +[![Build Status](https://travis-ci.org/mars/create-react-app-inner-buildpack.svg?branch=master)](https://travis-ci.org/mars/create-react-app-inner-buildpack) + +Development +----------- + +Use Ruby 1.9.3 as built-in to Cedar-14. + +Run tests: `bundle exec rake` \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f5dbc01 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +begin + require 'rspec/core/rake_task' + + RSpec::Core::RakeTask.new(:spec) + task :default => :spec + +rescue LoadError + # no rspec available +end \ No newline at end of file diff --git a/bin/compile b/bin/compile index 9d5f80e..031fa38 100755 --- a/bin/compile +++ b/bin/compile @@ -39,6 +39,16 @@ else echo '{ "root": "build/" }' > static.json fi +echo ' Enabling runtime environment variables' + +cra_dir="$BUILD_DIR/.heroku/create-react-app" +mkdir -p "$cra_dir" +cp "$BP_DIR/lib/injectable_env.rb" "$cra_dir/" + +profile_d_dir="$BUILD_DIR/.profile.d" +mkdir -p "$profile_d_dir" +cp "$BP_DIR/.profile.d/inject_react_app_env.sh" "$profile_d_dir/" + # Support env vars during build: # * `REACT_APP_*` # * https://github.com/facebookincubator/create-react-app/blob/v0.2.3/template/README.md#adding-custom-environment-variables diff --git a/lib/injectable_env.rb b/lib/injectable_env.rb new file mode 100644 index 0000000..17c544a --- /dev/null +++ b/lib/injectable_env.rb @@ -0,0 +1,51 @@ +# encoding: utf-8 +require 'json' + +class InjectableEnv + DefaultVarMatcher = /^REACT_APP_/ + Placeholder='{{REACT_APP_VARS_AS_JSON}}' + + def self.create(var_matcher=DefaultVarMatcher) + vars = ENV.find_all {|name,value| var_matcher===name } + + json = '{' + is_first = true + vars.each do |name,value| + json += ',' unless is_first + json += "#{escape(name)}:#{escape(value)}" + is_first = false + end + json += '}' + end + + def self.render(*args) + $stdout.write create(*args) + $stdout.flush + end + + def self.replace(file, *args) + injectee = IO.read(file) + return unless injectee.index(Placeholder) + + env = create(*args) + head,_,tail = injectee.partition(Placeholder) + injected = head + env + tail + File.open(file, 'w') do |f| + f.write(injected) + end + end + + # Escape JSON name/value double-quotes so payload can be injected + # into Webpack bundle where embedded in a double-quoted string. + # + def self.escape(v) + v.dup + .force_encoding('utf-8') # UTF-8 encoding for content + .to_json + .gsub(/\\\\/, '\\\\\\\\\\\\\\\\') # single slash in content + .gsub(/\\([bfnrt])/, '\\\\\\\\\1') # control sequence in content + .gsub(/([^\A])\"([^\Z])/, '\1\\\\\\"\2') # double-quote in content + .gsub(/(\A\"|\"\Z)/, '\\\"') # double-quote around JSON token + end + +end \ No newline at end of file diff --git a/spec/injectable_env_spec.rb b/spec/injectable_env_spec.rb new file mode 100644 index 0000000..d049f21 --- /dev/null +++ b/spec/injectable_env_spec.rb @@ -0,0 +1,126 @@ +# encoding: utf-8 +require './lib/injectable_env' +require 'yaml' +require 'tempfile' + +RSpec.describe InjectableEnv do + + describe '.create' do + it "returns empty object" do + expect(InjectableEnv.create).to eq('{}') + end + + describe 'for REACT_APP_ vars' do + before do + ENV['REACT_APP_HELLO'] = 'Hello World' + ENV['REACT_APP_EMOJI'] = '🍒🍊🍍' + ENV['REACT_APP_EMBEDDED_QUOTES'] = '"e=MC(2)"' + ENV['REACT_APP_SLASH_CONTENT'] = '\\' + ENV['REACT_APP_NEWLINE'] = "I am\na poet." + end + after do + ENV.delete 'REACT_APP_HELLO' + ENV.delete 'REACT_APP_EMOJI' + ENV.delete 'REACT_APP_EMBEDDED_QUOTES' + ENV.delete 'REACT_APP_SLASH_CONTENT' + ENV.delete 'REACT_APP_NEWLINE' + end + + it "returns entries" do + result = InjectableEnv.create + # puts result + # puts unescape(result) + object = JSON.parse(unescape(result)) + expect(object['REACT_APP_HELLO']).to eq('Hello World') + expect(object['REACT_APP_EMOJI']).to eq('🍒🍊🍍') + expect(object['REACT_APP_EMBEDDED_QUOTES']).to eq('"e=MC(2)"') + expect(object['REACT_APP_SLASH_CONTENT']).to eq('\\') + expect(object['REACT_APP_NEWLINE']).to eq("I am\na poet.") + end + end + + describe 'for unmatches vars' do + before do + ENV['ANOTHER_HELLO'] = 'Hello World' + end + after do + ENV.delete 'ANOTHER_HELLO' + end + + it "ignores them" do + result = InjectableEnv.create + object = JSON.parse(unescape(result)) + expect(object).not_to have_key('ANOTHER_HELLO') + end + end + end + + describe '.render' do + it "writes result to stdout" do + expect { InjectableEnv.render }.to output('{}').to_stdout + end + end + + describe '.replace' do + before do + ENV['REACT_APP_HELLO'] = "Hello\n\"World\" we \\ prices today" + end + after do + ENV.delete 'REACT_APP_HELLO' + end + + it "writes into file" do + begin + file = Tempfile.new('injectable_env_test') + file.write('var injected="{{REACT_APP_VARS_AS_JSON}}"') + file.rewind + + InjectableEnv.replace(file.path) + + expected_value='var injected="{\\"REACT_APP_HELLO\\":\\"Hello\\\\n\\\\\"World\\\\\" we \\\\\\\\ prices today\\"}"' + actual_value=file.read + expect(actual_value).to eq(expected_value) + ensure + if file + file.close + file.unlink + end + end + end + + it "does not write when the placeholder is missing" do + begin + file = Tempfile.new('injectable_env_test') + file.write('template is not present in file') + file.rewind + + InjectableEnv.replace(file.path) + + expected_value='template is not present in file' + actual_value=file.read + expect(actual_value).to eq(expected_value) + ensure + if file + file.close + file.unlink + end + end + end + end + + describe '.escape' do + it 'slash-escapes the JSON token double-quotes' do + expect(InjectableEnv.escape('value')).to eq('\\"value\\"') + end + it 'double-escapes double-quotes in the value' do + # This looks insane, but the six-slashes '\\\\\\' test for three '\\\' + expect(InjectableEnv.escape('"quoted"')).to eq('\\"\\\\\\"quoted\\\\\\"\\"') + end + end +end + +# For the sake of parsing the test output, +# undo the "injectable" JSON escape sequences. +def unescape(s) + YAML.load(%Q(---\n"#{s}"\n)) +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..47b39ce --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,103 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# The `.rspec` file also contains a few flags that are not defaults but that +# users commonly want. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end