rack-spa 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f987e16a684d7095a44f36c3aa3c829d16f13a6d6eff194b3275a2a546941afc
4
+ data.tar.gz: dc4df61474e03ef8c60d80f72a3dace7c32b17105a3466dbaede1e95d9bfc62b
5
+ SHA512:
6
+ metadata.gz: 8a31ce6bd3909f435824c945dce2f99037fa9acac5aca3dbd916bcbe61999c6bf93d2b0e47833217d37015980d50990cd88039ca85d0bd15074e535d1a1ea358
7
+ data.tar.gz: e62de5586131f91df9f0d5f52bcea43cc1cf525597448a8dd3f4904f342e82cddb3001094ddc13dea481022f89928f42fbb59ff6103cc34492bc4de45d6b3516
data/lib/rack/csp.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Rack
6
+ class Csp
7
+ def initialize(app, policy:)
8
+ @app = app
9
+ @policy = policy
10
+ end
11
+
12
+ def call(env)
13
+ status, headers, body = @app.call(env)
14
+ headers["Content-Security-Policy"] = @policy
15
+ [status, headers, body]
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "nokogiri"
5
+ require "json"
6
+
7
+ # Allow dynamic configuration of a SPA.
8
+ # When the backend app starts up, it should run #emplace.
9
+ # This will 1) copy the index.html to a 'backup' location
10
+ # if it does not exist, 2) replace a placeholder string
11
+ # in the index.html with the given keys and values
12
+ # (use .pick_env_vars to pull everything like 'REACT_APP_'),
13
+ # and write it out to index.html.
14
+ #
15
+ # IMPORTANT: This sort of dynamic config writing is not normal
16
+ # for SPAs so needs some further explanation.
17
+ # The build process should be exactly the same;
18
+ # for example, you'd still run `npm run build`,
19
+ # and generate a totally normal build output.
20
+ # It's the *backend* running that modifies index.html
21
+ # (and creates index.html.original) *at backend startup*,
22
+ # not at build time.
23
+ module Rack
24
+ class DynamicConfigWriter
25
+ GLOBAL_ASSIGN = "window.rackDynamicConfig"
26
+ BACKUP_SUFFIX = ".original"
27
+
28
+ def initialize(
29
+ index_html_path,
30
+ global_assign: GLOBAL_ASSIGN,
31
+ backup_suffix: BACKUP_SUFFIX
32
+ )
33
+ @index_html_path = index_html_path
34
+ @global_assign = global_assign
35
+ @index_html_backup = index_html_path + backup_suffix
36
+ end
37
+
38
+ def emplace(keys_and_values)
39
+ self.prepare
40
+ json = JSON.generate(keys_and_values)
41
+ script = "#{@global_assign}=#{json}"
42
+ ::File.open(@index_html_backup) do |f|
43
+ doc = Nokogiri::HTML5(f)
44
+ doc.at("head").prepend_child("<script>#{script}</script>")
45
+ ::File.write(@index_html_path, doc.serialize)
46
+ end
47
+ end
48
+
49
+ protected def prepare
50
+ return if ::File.exist?(@index_html_backup)
51
+ ::FileUtils.move(@index_html_path, @index_html_backup)
52
+ end
53
+
54
+ def self.pick_env(regex_or_prefix)
55
+ return ENV.to_a.select { |(k, _v)| k.start_with?(regex_or_prefix) }.to_h if regex_or_prefix.is_a?(String)
56
+ return ENV.to_a.select { |(k, _v)| regex_or_prefix.match?(k) }.to_h
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Rack
6
+ class Immutable
7
+ # By default, match strings like 'main.abc123.js'.
8
+ # Assume the fingerprint is a Git SHA value of at least 6 characters,
9
+ # comes before the extension, and has some preceding segment of the path.
10
+ DEFAULT_MATCH = /.*\.[A-Za-z\d]{6}[A-Za-z\d]*\.[a-z]+/
11
+ IMMUTABLE = "public, max-age=604800, immutable"
12
+
13
+ def initialize(app, match: nil, cache_control: nil)
14
+ @app = app
15
+ @match = match || DEFAULT_MATCH
16
+ @cache_control = cache_control || IMMUTABLE
17
+ end
18
+
19
+ def call(env)
20
+ status, headers, body = @app.call(env)
21
+ headers[Rack::CACHE_CONTROL] = @cache_control if
22
+ self._matches(env["PATH_INFO"], env)
23
+ return status, headers, body
24
+ end
25
+
26
+ def _matches(path, env)
27
+ return @match == path if @match.is_a?(String)
28
+ return @match.match?(path) if @match.is_a?(Regexp)
29
+ return @match.call(env)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ # Call the inner proc as the app.
6
+ # If proc returns nil, keep calling middleware;
7
+ # if proc returns a result, return it.
8
+ module Rack
9
+ class LambdaApp
10
+ # LambdaApp.new returns itself, which has a +new+ method so it can
11
+ # be used like normal middleware.
12
+ def initialize(proc)
13
+ @proc = proc
14
+ end
15
+
16
+ def new(app)
17
+ @app = app
18
+ return self
19
+ end
20
+
21
+ def call(env)
22
+ result = @proc.call(env)
23
+ return result if result
24
+ return @app.call(env)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ # This is based almost entirely on
6
+ # https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/middleware/remote_ip.rb
7
+ # It hews as close to it as possible, while removign the dependency on ActionDispatch,
8
+ # which we do not want/need. Unlike ActiveSupport, it is pretty tightly tied to Rails.
9
+ #
10
+ # ORIGINAL COMMENT
11
+ #
12
+ # This middleware calculates the IP address of the remote client that is
13
+ # making the request. It does this by checking various headers that could
14
+ # contain the address, and then picking the last-set address that is not
15
+ # on the list of trusted IPs. This follows the precedent set by e.g.
16
+ # {the Tomcat server}[https://issues.apache.org/bugzilla/show_bug.cgi?id=50453].
17
+ # A more detailed explanation of the algorithm is given at GetIp#calculate_ip.
18
+ #
19
+ # Some Rack servers concatenate repeated headers, like {HTTP RFC 2616}[https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2]
20
+ # requires. Some Rack servers simply drop preceding headers, and only report
21
+ # the value that was {given in the last header}[https://andre.arko.net/2011/12/26/repeated-headers-and-ruby-web-servers].
22
+ # If you are behind multiple proxy servers (like NGINX to HAProxy to Unicorn)
23
+ # then you should test your Rack server to make sure your data is good.
24
+ #
25
+ # IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING.
26
+ # This middleware assumes that there is at least one proxy sitting around
27
+ # and setting headers with the client's remote IP address. If you don't use
28
+ # a proxy, because you are hosted on e.g. Heroku without SSL, any client can
29
+ # claim to have any IP address by setting the +X-Forwarded-For+ header. If you
30
+ # care about that, then you need to explicitly drop or ignore those headers
31
+ # sometime before this middleware runs. Alternatively, remove this middleware
32
+ # to avoid inadvertently relying on it.
33
+ module Rack
34
+ class RemoteIp
35
+ class IpSpoofAttackError < StandardError; end
36
+
37
+ # The default trusted IPs list simply includes IP addresses that are
38
+ # guaranteed by the IP specification to be private addresses. Those will
39
+ # not be the ultimate client IP in production, and so are discarded. See
40
+ # https://en.wikipedia.org/wiki/Private_network for details.
41
+ TRUSTED_PROXIES = [
42
+ "127.0.0.0/8", # localhost IPv4 range, per RFC-3330
43
+ "::1", # localhost IPv6
44
+ "fc00::/7", # private IPv6 range fc00::/7
45
+ "10.0.0.0/8", # private IPv4 range 10.x.x.x
46
+ "172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255
47
+ "192.168.0.0/16", # private IPv4 range 192.168.x.x
48
+ ].map { |proxy| IPAddr.new(proxy) }
49
+
50
+ attr_reader :check_ip, :proxies
51
+
52
+ # Create a new +RemoteIp+ middleware instance.
53
+ #
54
+ # The +skip_ip_spoofing_check+ option is off by default (so spoofing is enabled).
55
+ # When on, an exception
56
+ # is raised if it looks like the client is trying to lie about its own IP
57
+ # address. It makes sense to turn off this check on sites aimed at non-IP
58
+ # clients (like WAP devices), or behind proxies that set headers in an
59
+ # incorrect or confusing way (like AWS ELB).
60
+ #
61
+ # The +custom_proxies+ argument can take an enumerable which will be used
62
+ # instead of +TRUSTED_PROXIES+. Any proxy setup will put the value you
63
+ # want in the middle (or at the beginning) of the +X-Forwarded-For+ list,
64
+ # with your proxy servers after it. If your proxies aren't removed, pass
65
+ # them in via the +custom_proxies+ parameter. That way, the middleware will
66
+ # ignore those IP addresses, and return the one that you want.
67
+ def initialize(app, skip_ip_spoofing_check: false, custom_proxies: [])
68
+ @app = app
69
+ @check_ip = !skip_ip_spoofing_check
70
+ @proxies = TRUSTED_PROXIES + custom_proxies
71
+ end
72
+
73
+ # Since the IP address may not be needed, we store the object here
74
+ # without calculating the IP to keep from slowing down the majority of
75
+ # requests. For those requests that do need to know the IP, the
76
+ # GetIp#calculate_ip method will calculate the memoized client IP address.
77
+ def call(env)
78
+ env["remote_ip"] = GetIp.new(env, self.check_ip, self.proxies)
79
+ @app.call(env)
80
+ end
81
+
82
+ # The GetIp class exists as a way to defer processing of the request data
83
+ # into an actual IP address. If env['remote_ip'].to_s is called,
84
+ # this class will calculate the value and then memoize it.
85
+ class GetIp
86
+ def initialize(env, check_ip, proxies)
87
+ @env = env
88
+ @check_ip = check_ip
89
+ @proxies = proxies
90
+ end
91
+
92
+ def remote_addr = @remote_addr ||= @env["REMOTE_ADDR"]
93
+ def client_ip = @client_ip ||= @env["HTTP_CLIENT_IP"]
94
+ def x_forwarded_for = @x_forwarded_for ||= @env["HTTP_X_FORWARDED_FOR"]
95
+
96
+ # Sort through the various IP address headers, looking for the IP most
97
+ # likely to be the address of the actual remote client making this
98
+ # request.
99
+ #
100
+ # REMOTE_ADDR will be correct if the request is made directly against the
101
+ # Ruby process, on e.g. Heroku. When the request is proxied by another
102
+ # server like HAProxy or NGINX, the IP address that made the original
103
+ # request will be put in an +X-Forwarded-For+ header. If there are multiple
104
+ # proxies, that header may contain a list of IPs. Other proxy services
105
+ # set the +Client-Ip+ header instead, so we check that too.
106
+ #
107
+ # As discussed in {this post about Rails IP Spoofing}[https://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/],
108
+ # while the first IP in the list is likely to be the "originating" IP,
109
+ # it could also have been set by the client maliciously.
110
+ #
111
+ # In order to find the first address that is (probably) accurate, we
112
+ # take the list of IPs, remove known and trusted proxies, and then take
113
+ # the last address left, which was presumably set by one of those proxies.
114
+ def calculate_ip
115
+ # Set by the Rack web server, this is a single value.
116
+ remote_addr = ips_from(self.remote_addr).last
117
+
118
+ # Could be a CSV list and/or repeated headers that were concatenated.
119
+ client_ips = ips_from(self.client_ip).reverse
120
+ forwarded_ips = ips_from(self.x_forwarded_for).reverse
121
+
122
+ # +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set.
123
+ # If they are both set, it means that either:
124
+ #
125
+ # 1) This request passed through two proxies with incompatible IP header
126
+ # conventions.
127
+ # 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+
128
+ # (whichever the proxy servers weren't using) themselves.
129
+ #
130
+ # Either way, there is no way for us to determine which header is the
131
+ # right one after the fact. Since we have no idea, if we are concerned
132
+ # about IP spoofing we need to give up and explode. (If you're not
133
+ # concerned about IP spoofing you can turn the +ip_spoofing_check+
134
+ # option off.)
135
+ should_check_ip = @check_ip && client_ips.last && forwarded_ips.last
136
+ if should_check_ip && !forwarded_ips.include?(client_ips.last)
137
+ # We don't know which came from the proxy, and which from the user
138
+ raise IpSpoofAttackError, "IP spoofing attack?! " \
139
+ "HTTP_CLIENT_IP=#{env['HTTP_CLIENT_IP'].inspect} " \
140
+ "HTTP_X_FORWARDED_FOR=#{env['HTTP_X_FORWARDED_FOR'].inspect}"
141
+ end
142
+
143
+ # We assume these things about the IP headers:
144
+ #
145
+ # - X-Forwarded-For will be a list of IPs, one per proxy, or blank
146
+ # - Client-Ip is propagated from the outermost proxy, or is blank
147
+ # - REMOTE_ADDR will be the IP that made the request to Rack
148
+ ips = [forwarded_ips, client_ips].flatten.compact
149
+
150
+ # If every single IP option is in the trusted list, return the IP
151
+ # that's furthest away
152
+ filter_proxies(ips + [remote_addr]).first || ips.last || remote_addr
153
+ end
154
+
155
+ # Memoizes the value returned by #calculate_ip and returns it for
156
+ # ActionDispatch::Request to use.
157
+ def to_s
158
+ return @to_s ||= self.calculate_ip
159
+ end
160
+
161
+ private def ips_from(header)
162
+ return [] unless header
163
+ # Split the comma-separated list into an array of strings.
164
+ ips = header.strip.split(/[,\s]+/)
165
+ ips.select do |ip|
166
+ # Only return IPs that are valid according to the IPAddr#new method.
167
+ range = IPAddr.new(ip).to_range
168
+ # We want to make sure nobody is sneaking a netmask in.
169
+ range.begin == range.end
170
+ rescue ArgumentError
171
+ nil
172
+ end
173
+ end
174
+
175
+ private def filter_proxies(ips)
176
+ ips.reject do |ip|
177
+ @proxies.any? { |proxy| proxy === ip }
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ # Set the Service-Worker-Allowed header to the given scope
6
+ # if this is a service worker request.
7
+ module Rack
8
+ class ServiceWorkerAllowed
9
+ def initialize(app, scope:)
10
+ @app = app
11
+ @scope = scope
12
+ end
13
+
14
+ def call(env)
15
+ status, headers, body = @app.call(env)
16
+ headers["Service-Worker-Allowed"] = @scope if env["HTTP_SERVICE_WORKER"]
17
+ return status, headers, body
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ # Redirect a request based on the route it matches.
6
+ module Rack
7
+ class SimpleRedirect
8
+ # The keys in +routes+ can be strings, regular expressions, or callables.
9
+ # For a key that matches (using == for strings, .match?(path) for regexes,
10
+ # or call(env) for callables), the Location in the redirect
11
+ # is equal to the value of the key, or if the value is a callable,
12
+ # the returned result of calling the value with env.
13
+ def initialize(app, routes: {}, status: 302)
14
+ @app = app
15
+ @routes = routes
16
+ @status = status
17
+ end
18
+
19
+ def call(env)
20
+ path = env["PATH_INFO"]
21
+ loc = nil
22
+ @routes.each do |route, result|
23
+ if self._matches(path, env, route)
24
+ loc = result.respond_to?(:call) ? result[env] : result
25
+ break
26
+ end
27
+ end
28
+ return @app.call(env) if loc.nil?
29
+ return [@status, {"Location" => loc}, []]
30
+ end
31
+
32
+ def _matches(path, env, route)
33
+ return route == path if route.is_a?(String)
34
+ return route.match?(path) if route.is_a?(Regexp)
35
+ return route.call(env)
36
+ end
37
+
38
+ def _check_routes_opts(h)
39
+ h.each do |k, v|
40
+ case k
41
+ when String, Regexp, Proc
42
+ nil
43
+ else
44
+ raise "SimpleRedirect routes keys must be strings, regexes, or procs"
45
+ end
46
+ case v
47
+ when String, Proc
48
+ nil
49
+ else
50
+ raise "SimpleRedirect routes values must be strings or procs"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/immutable"
4
+ require "rack/lambda_app"
5
+ require "rack/simple_redirect"
6
+ require "rack/spa_rewrite"
7
+
8
+ module Rack
9
+ class SpaApp
10
+ def self.dependencies(build_folder, immutable: true, enforce_ssl: true, service_worker_allowed: nil)
11
+ result = []
12
+ result << [Rack::SslEnforcer, {redirect_html: false}] if enforce_ssl
13
+ result << [Rack::ConditionalGet, {}]
14
+ result << [Rack::ETag, {}]
15
+ result << [Rack::Immutable, {match: immutable.is_a?(TrueClass) ? nil : immutable}] if immutable
16
+ result << [Rack::SpaRewrite, {index_path: "#{build_folder}/index.html", html_only: true}]
17
+ result << [Rack::ServiceWorkerAllowed, {scope: service_worker_allowed}] if service_worker_allowed
18
+ result << [Rack::Static, {urls: [""], root: build_folder.to_s, cascade: true}]
19
+ result << [Rack::SpaRewrite, {index_path: "#{build_folder}/index.html", html_only: false}]
20
+ return result
21
+ end
22
+
23
+ def self.install(builder, dependencies)
24
+ dependencies.each { |cls, opts| builder.use(cls, **opts) }
25
+ end
26
+
27
+ def self.run(builder)
28
+ builder.run Rack::LambdaApp.new(->(_) { raise "Should not see SpaApp fallback" })
29
+ end
30
+
31
+ def self.run_spa_app(builder, build_folder, enforce_ssl: true, immutable: true, **kw)
32
+ deps = self.dependencies(build_folder, enforce_ssl:, immutable:, **kw)
33
+ self.install(builder, deps)
34
+ self.run(builder)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Rack
6
+ class SpaRewrite
7
+ ALLOWED_VERBS = ["GET", "HEAD", "OPTIONS"].freeze
8
+ ALLOW_HEADER = ALLOWED_VERBS.join(", ")
9
+
10
+ def initialize(app, index_path:, html_only:)
11
+ @app = app
12
+ @index_path = index_path
13
+ @html_only = html_only
14
+ begin
15
+ @index_mtime = ::File.mtime(@index_path).httpdate
16
+ rescue Errno::ENOENT
17
+ @index_mtime = Time.at(0)
18
+ end
19
+ @index_bytes = nil
20
+ @head = Rack::Head.new(->(env) { get env })
21
+ end
22
+
23
+ def call(env)
24
+ # HEAD requests drop the response body, including 4xx error messages.
25
+ @head.call env
26
+ end
27
+
28
+ def get(env)
29
+ request = Rack::Request.new env
30
+ return @app.call(env) if @html_only && !request.path_info.end_with?(".html")
31
+ return [405, {"Allow" => ALLOW_HEADER}, ["Method Not Allowed"]] unless
32
+ ALLOWED_VERBS.include?(request.request_method)
33
+
34
+ path_info = Rack::Utils.unescape_path(request.path_info)
35
+ return [400, {}, ["Bad Request"]] unless Rack::Utils.valid_path?(path_info)
36
+
37
+ return [200, {"Allow" => ALLOW_HEADER, Rack::CONTENT_LENGTH => "0"}, []] if
38
+ request.options?
39
+
40
+ lastmod = ::File.mtime(@index_path)
41
+ lastmodhttp = lastmod.httpdate
42
+ return [304, {}, []] if request.get_header("HTTP_IF_MODIFIED_SINCE") == lastmodhttp
43
+
44
+ @index_bytes = ::File.read(@index_path) if @index_bytes.nil? || @index_mtime < lastmodhttp
45
+ headers = {
46
+ "content-length" => @index_bytes.bytesize.to_s,
47
+ "content-type" => "text/html",
48
+ "last-modified" => lastmodhttp,
49
+ }
50
+ return [200, headers, [@index_bytes]]
51
+ end
52
+ end
53
+ end
data/lib/rack_spa.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackSpa
4
+ VERSION = "0.10.0"
5
+ end
metadata ADDED
@@ -0,0 +1,203 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-spa
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0
5
+ platform: ruby
6
+ authors:
7
+ - Lithic Tech
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-03-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.10'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '4.0'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '2.0'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rackup
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.1'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.1'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.10'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.10'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec-core
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.10'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.10'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec-temp_dir
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.1'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.1'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: 1.25.1
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: 1.25.1
117
+ - !ruby/object:Gem::Dependency
118
+ name: rubocop-performance
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: 1.13.3
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: 1.13.3
131
+ - !ruby/object:Gem::Dependency
132
+ name: simplecov
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: timecop
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.9'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '0.9'
159
+ description: 'Rack middlewares to make building and serving a Single Page App from
160
+ a Ruby Rack app easy.
161
+
162
+ '
163
+ email: hello@lithic.tech
164
+ executables: []
165
+ extensions: []
166
+ extra_rdoc_files: []
167
+ files:
168
+ - lib/rack/csp.rb
169
+ - lib/rack/dynamic_config_writer.rb
170
+ - lib/rack/immutable.rb
171
+ - lib/rack/lambda_app.rb
172
+ - lib/rack/remote_ip.rb
173
+ - lib/rack/service_worker_allowed.rb
174
+ - lib/rack/simple_redirect.rb
175
+ - lib/rack/spa_app.rb
176
+ - lib/rack/spa_rewrite.rb
177
+ - lib/rack_spa.rb
178
+ homepage: https://github.com/lithictech/rack-spa
179
+ licenses:
180
+ - MIT
181
+ metadata:
182
+ rubygems_mfa_required: 'true'
183
+ post_install_message:
184
+ rdoc_options: []
185
+ require_paths:
186
+ - lib
187
+ required_ruby_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: 3.1.0
192
+ required_rubygems_version: !ruby/object:Gem::Requirement
193
+ requirements:
194
+ - - ">="
195
+ - !ruby/object:Gem::Version
196
+ version: '0'
197
+ requirements: []
198
+ rubygems_version: 3.3.7
199
+ signing_key:
200
+ specification_version: 4
201
+ summary: Rack middlewares to make building and serving a Single Page App from a Ruby
202
+ Rack app easy.
203
+ test_files: []