condenser 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +1 -0
  4. data/lib/condenser.rb +108 -0
  5. data/lib/condenser/asset.rb +221 -0
  6. data/lib/condenser/cache/memory_store.rb +92 -0
  7. data/lib/condenser/cache/null_store.rb +37 -0
  8. data/lib/condenser/context.rb +272 -0
  9. data/lib/condenser/encoding_utils.rb +155 -0
  10. data/lib/condenser/environment.rb +50 -0
  11. data/lib/condenser/errors.rb +11 -0
  12. data/lib/condenser/export.rb +68 -0
  13. data/lib/condenser/manifest.rb +89 -0
  14. data/lib/condenser/pipeline.rb +82 -0
  15. data/lib/condenser/processors/babel.min.js +25 -0
  16. data/lib/condenser/processors/babel_processor.rb +87 -0
  17. data/lib/condenser/processors/node_processor.rb +38 -0
  18. data/lib/condenser/processors/rollup.js +24083 -0
  19. data/lib/condenser/processors/rollup_processor.rb +164 -0
  20. data/lib/condenser/processors/sass_importer.rb +81 -0
  21. data/lib/condenser/processors/sass_processor.rb +300 -0
  22. data/lib/condenser/resolve.rb +202 -0
  23. data/lib/condenser/server.rb +307 -0
  24. data/lib/condenser/templating_engine/erb.rb +21 -0
  25. data/lib/condenser/utils.rb +32 -0
  26. data/lib/condenser/version.rb +3 -0
  27. data/lib/condenser/writers/file_writer.rb +28 -0
  28. data/lib/condenser/writers/zlib_writer.rb +42 -0
  29. data/test/cache_test.rb +24 -0
  30. data/test/environment_test.rb +49 -0
  31. data/test/manifest_test.rb +513 -0
  32. data/test/pipeline_test.rb +31 -0
  33. data/test/preprocessor/babel_test.rb +21 -0
  34. data/test/processors/rollup_test.rb +71 -0
  35. data/test/resolve_test.rb +105 -0
  36. data/test/server_test.rb +361 -0
  37. data/test/templates/erb_test.rb +18 -0
  38. data/test/test_helper.rb +68 -0
  39. data/test/transformers/scss_test.rb +49 -0
  40. metadata +193 -0
@@ -0,0 +1,202 @@
1
+ class Condenser
2
+ module Resolve
3
+
4
+ def initialize(root)
5
+ @reverse_mapping = nil
6
+ end
7
+
8
+ def resolve(filename, base=nil, accept: nil, ignore: [])
9
+ dirname, basename, extensions, mime_types = decompose_path(filename, base)
10
+ results = []
11
+
12
+ accept ||= mime_types.empty? ? ['*/*'] : mime_types
13
+ accept = Array(accept)
14
+
15
+ paths = if dirname&.start_with?('/')
16
+ if pat = path.find { |pa| dirname.start_with?(pa) }
17
+ dirname.delete_prefix!(pat)
18
+ dirname.delete_prefix!('/')
19
+ [pat]
20
+ else
21
+ []
22
+ end
23
+ else
24
+ path
25
+ end
26
+
27
+ paths.each do |path|
28
+ glob = path
29
+ glob = File.join(glob, dirname) if dirname
30
+ glob = File.join(glob, basename)
31
+ glob << '.*' unless glob.end_with?('*')
32
+
33
+ Dir.glob(glob).sort.each do |f|
34
+ next if !File.file?(f) || ignore.include?(f)
35
+
36
+ f_dirname, f_basename, f_extensions, f_mime_types = decompose_path(f)
37
+ if (basename == '*' || basename == f_basename)
38
+ if accept == ['*/*'] || mime_type_match_accept?(f_mime_types, accept)
39
+ asset_dir = f_dirname.delete_prefix(path).delete_prefix('/')
40
+ asset_basename = f_basename + f_extensions.join('')
41
+ asset_filename = asset_dir.empty? ? asset_basename : File.join(asset_dir, asset_basename)
42
+ results << Asset.new(self, {
43
+ filename: asset_filename,
44
+ content_types: f_mime_types,
45
+ source_file: f,
46
+ source_path: path
47
+ })
48
+ end
49
+
50
+ reverse_mapping[f_mime_types]&.each do |derivative_mime_types|
51
+ if accept == ['*/*'] || mime_type_match_accept?(derivative_mime_types, accept)
52
+ asset_dir = f_dirname.delete_prefix(path).delete_prefix('/')
53
+ asset_basename = f_basename + derivative_mime_types.map { |t| @mime_types[t][:extensions].first }.join('')
54
+ asset_filename = asset_dir.empty? ? asset_basename : File.join(asset_dir, asset_basename)
55
+ results << Asset.new(self, {
56
+ filename: asset_filename,
57
+ content_types: derivative_mime_types,
58
+ source_file: f,
59
+ source_path: path
60
+ })
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ results = results.group_by do |a|
68
+ accept.find_index { |m| match_mime_types?(a.content_types, m) }
69
+ end
70
+
71
+ results = results.keys.sort.reduce([]) do |c, key|
72
+ c += results[key].sort_by(&:filename)
73
+ end
74
+
75
+ results = results.map { |a| a.basepath }.uniq.map {|fn| results.find {|r| r.filename.sub(/\.(\w+)$/, '') == fn}}
76
+
77
+ results.sort_by(&:filename)
78
+ end
79
+
80
+ def resolve!(filename, base=nil, **kargs)
81
+ assets = resolve(filename, base, **kargs)
82
+ if assets.empty?
83
+ raise FileNotFound, "couldn't find file '#{filename}'"
84
+ else
85
+ assets
86
+ end
87
+ end
88
+
89
+ def find(filename, base=nil, **kargs)
90
+ resolve(filename, base, **kargs).first
91
+ end
92
+
93
+ def find!(filename, base=nil, **kargs)
94
+ resolve!(filename, base, **kargs).first
95
+ end
96
+
97
+ def [](filename)
98
+ find!(filename).export
99
+ end
100
+
101
+ def find_export(filename, base=nil, **kargs)
102
+ asset = resolve(filename, base, **kargs).first
103
+ asset&.export
104
+ end
105
+
106
+
107
+ def decompose_path(path, base=nil)
108
+ dirname = path.index('/') ? File.dirname(path) : nil
109
+ if base
110
+ dirname = File.expand_path(dirname, base)
111
+ end
112
+ _, basename, extensions = path.match(/([^\.\/]+)(\.[^\/]*)?$/).to_a
113
+
114
+ if extensions.nil? && basename == '*'
115
+ extensions = nil
116
+ mime_types = []
117
+ elsif extensions.nil?
118
+ mime_types = []
119
+ else
120
+ exts = []
121
+ while !extensions.empty?
122
+ matching_extensions = @extensions.keys.select { |e| extensions.end_with?(e) }
123
+ if matching_extensions.empty?
124
+ basename << extensions
125
+ break
126
+ # raise 'unkown mime'
127
+ else
128
+ matching_extensions.sort_by! { |e| -e.length }
129
+ exts.unshift(matching_extensions.first)
130
+ extensions.delete_suffix!(matching_extensions.first)
131
+ end
132
+ end
133
+ extensions = exts
134
+ mime_types = extensions.map { |k| @extensions[k] }
135
+ end
136
+
137
+
138
+ [ dirname, basename, extensions, mime_types ]
139
+ end
140
+
141
+ def reverse_mapping
142
+ return @reverse_mapping if @reverse_mapping
143
+ map = {}
144
+ @mime_types.each_key do |source_mime_type|
145
+ to_mime_type = source_mime_type
146
+
147
+ ([nil] + (@transformers[source_mime_type]&.keys || [])).each do |transform_mime_type|
148
+ to_mime_type = transform_mime_type if transform_mime_type
149
+
150
+ ([nil] + @templates.keys).each do |template_mime_type|
151
+ from_mimes = [source_mime_type, template_mime_type].compact
152
+ to_mime_types = [to_mime_type].compact
153
+ if from_mimes != to_mime_types
154
+ map[from_mimes] ||= Set.new
155
+ map[from_mimes] << to_mime_types
156
+ end
157
+ end
158
+
159
+ end
160
+ end
161
+ $map = map
162
+ end
163
+
164
+ def writers_for_mime_type(mime_type)
165
+ @writers.select { |m, e| match_mime_type?(mime_type, m) }.values.reduce(&:+)
166
+ end
167
+
168
+ def match_mime_types?(value, matcher)
169
+ matcher = Array(matcher)
170
+ value = Array(value)
171
+
172
+ if matcher.length == 1 && matcher.last == '*/*'
173
+ true
174
+ else
175
+ value.length == matcher.length && value.zip(matcher).all? { |v, m| match_mime_type?(v, m) }
176
+ end
177
+ end
178
+
179
+ def mime_type_match_accept?(value, accept)
180
+ accept.any? do |a|
181
+ match_mime_types?(value, Array(a))
182
+ end
183
+ end
184
+
185
+ # Public: Test mime type against mime range.
186
+ #
187
+ # match_mime_type?('text/html', 'text/*') => true
188
+ # match_mime_type?('text/plain', '*') => true
189
+ # match_mime_type?('text/html', 'application/json') => false
190
+ #
191
+ # Returns true if the given value is a mime match for the given mime match
192
+ # specification, false otherwise.
193
+ def match_mime_type?(value, matcher)
194
+ v1, v2 = value.split('/'.freeze, 2)
195
+ m1, m2 = matcher.split('/'.freeze, 2)
196
+ (m1 == '*'.freeze || v1 == m1) && (m2.nil? || m2 == '*'.freeze || m2 == v2)
197
+ end
198
+
199
+ end
200
+ end
201
+
202
+
@@ -0,0 +1,307 @@
1
+ require 'set'
2
+ require 'time'
3
+ require 'rack/utils'
4
+
5
+ class Condenser
6
+ class Server
7
+
8
+ ALLOWED_REQUEST_METHODS = ['GET', 'HEAD'].to_set.freeze
9
+
10
+ def initialize(condenser)
11
+ @condenser = condenser
12
+ end
13
+
14
+ def logger
15
+ @condenser.logger
16
+ end
17
+
18
+ # `call` implements the Rack 1.x specification which accepts an
19
+ # `env` Hash and returns a three item tuple with the status code,
20
+ # headers, and body.
21
+ #
22
+ # Mapping your environment at a url prefix will serve all assets
23
+ # in the path.
24
+ #
25
+ # map "/assets" do
26
+ # run Condenser::Server.new(condenser)
27
+ # end
28
+ #
29
+ # A request for `"/assets/foo/bar.js"` will search your
30
+ # environment for `"foo/bar.js"`.
31
+ def call(env)
32
+ start_time = Time.now.to_f
33
+ time_elapsed = lambda { ((Time.now.to_f - start_time) * 1000).to_i }
34
+
35
+ if !ALLOWED_REQUEST_METHODS.include?(env['REQUEST_METHOD'])
36
+ return method_not_allowed_response
37
+ end
38
+
39
+ msg = "Served asset #{env['PATH_INFO']} -"
40
+
41
+ # Extract the path from everything after the leading slash
42
+ path = Rack::Utils.unescape(env['PATH_INFO'].to_s.sub(/^\//, ''))
43
+
44
+ if !path.valid_encoding?
45
+ return bad_request_response(env)
46
+ end
47
+
48
+ # Strip fingerprint
49
+ if fingerprint = path_fingerprint(path)
50
+ path = path.sub("-#{fingerprint}", '')
51
+ end
52
+
53
+ # URLs containing a `".."` are rejected for security reasons.
54
+ if forbidden_request?(path)
55
+ return forbidden_response(env)
56
+ end
57
+
58
+ if fingerprint
59
+ if_match = fingerprint
60
+ elsif env['HTTP_IF_MATCH']
61
+ if_match = env['HTTP_IF_MATCH'][/"(\w+)"$/, 1]
62
+ end
63
+
64
+ if env['HTTP_IF_NONE_MATCH']
65
+ if_none_match = env['HTTP_IF_NONE_MATCH'][/"(\w+)"$/, 1]
66
+ end
67
+
68
+ # Look up the asset.
69
+ asset = @condenser.find_export(path)
70
+
71
+ if asset.nil?
72
+ status = :not_found
73
+ elsif fingerprint && asset.etag != fingerprint
74
+ status = :not_found
75
+ elsif if_match && asset.etag != if_match
76
+ status = :precondition_failed
77
+ elsif if_none_match && asset.etag == if_none_match
78
+ status = :not_modified
79
+ else
80
+ status = :ok
81
+ end
82
+
83
+ case status
84
+ when :ok
85
+ logger.info "#{msg} 200 OK (#{time_elapsed.call}ms)"
86
+ ok_response(asset, env)
87
+ when :not_modified
88
+ logger.info "#{msg} 304 Not Modified (#{time_elapsed.call}ms)"
89
+ not_modified_response(env, if_none_match)
90
+ when :not_found
91
+ logger.info "#{msg} 404 Not Found (#{time_elapsed.call}ms)"
92
+ not_found_response(env)
93
+ when :precondition_failed
94
+ logger.info "#{msg} 412 Precondition Failed (#{time_elapsed.call}ms)"
95
+ precondition_failed_response(env)
96
+ end
97
+ rescue StandardError => e
98
+ logger.error "Error compiling asset #{path}:"
99
+ logger.error "#{e.class.name}: #{e.message}"
100
+
101
+ case File.extname(path)
102
+ when ".js"
103
+ # Re-throw JavaScript asset exceptions to the browser
104
+ logger.info "#{msg} 500 Internal Server Error\n\n"
105
+ return javascript_exception_response(e)
106
+ when ".css"
107
+ # Display CSS asset exceptions in the browser
108
+ logger.info "#{msg} 500 Internal Server Error\n\n"
109
+ return css_exception_response(e)
110
+ else
111
+ raise
112
+ end
113
+ end
114
+
115
+ private
116
+ def forbidden_request?(path)
117
+ # Prevent access to files elsewhere on the file system
118
+ #
119
+ # http://example.org/assets/../../../etc/passwd
120
+ #
121
+ path.include?("..") || absolute_path?(path)
122
+ end
123
+
124
+ def head_request?(env)
125
+ env['REQUEST_METHOD'] == 'HEAD'
126
+ end
127
+
128
+ # Returns a 200 OK response tuple
129
+ def ok_response(asset, env)
130
+ if head_request?(env)
131
+ [ 200, headers(env, asset, 0), [] ]
132
+ else
133
+ [ 200, headers(env, asset, asset.length), [asset.to_s] ]
134
+ end
135
+ end
136
+
137
+ # Returns a 304 Not Modified response tuple
138
+ def not_modified_response(env, etag)
139
+ [ 304, cache_headers(env, etag), [] ]
140
+ end
141
+
142
+ # Returns a 400 Forbidden response tuple
143
+ def bad_request_response(env)
144
+ if head_request?(env)
145
+ [ 400, { "Content-Type" => "text/plain", "Content-Length" => "0" }, [] ]
146
+ else
147
+ [ 400, { "Content-Type" => "text/plain", "Content-Length" => "11" }, [ "Bad Request" ] ]
148
+ end
149
+ end
150
+
151
+ # Returns a 403 Forbidden response tuple
152
+ def forbidden_response(env)
153
+ if head_request?(env)
154
+ [ 403, { "Content-Type" => "text/plain", "Content-Length" => "0" }, [] ]
155
+ else
156
+ [ 403, { "Content-Type" => "text/plain", "Content-Length" => "9" }, [ "Forbidden" ] ]
157
+ end
158
+ end
159
+
160
+ # Returns a 404 Not Found response tuple
161
+ def not_found_response(env)
162
+ if head_request?(env)
163
+ [ 404, { "Content-Type" => "text/plain", "Content-Length" => "0", "X-Cascade" => "pass" }, [] ]
164
+ else
165
+ [ 404, { "Content-Type" => "text/plain", "Content-Length" => "9", "X-Cascade" => "pass" }, [ "Not found" ] ]
166
+ end
167
+ end
168
+
169
+ def method_not_allowed_response
170
+ [ 405, { "Content-Type" => "text/plain", "Content-Length" => "18" }, [ "Method Not Allowed" ] ]
171
+ end
172
+
173
+ def precondition_failed_response(env)
174
+ if head_request?(env)
175
+ [ 412, { "Content-Type" => "text/plain", "Content-Length" => "0", "X-Cascade" => "pass" }, [] ]
176
+ else
177
+ [ 412, { "Content-Type" => "text/plain", "Content-Length" => "19", "X-Cascade" => "pass" }, [ "Precondition Failed" ] ]
178
+ end
179
+ end
180
+
181
+ # Returns a JavaScript response that re-throws a Ruby exception
182
+ # in the browser
183
+ def javascript_exception_response(exception)
184
+ err = "#{exception.class.name}: #{exception.message}\n (in #{exception.backtrace[0]})"
185
+ body = "throw Error(#{err.inspect})"
186
+ [ 200, { "Content-Type" => "application/javascript", "Content-Length" => body.bytesize.to_s }, [ body ] ]
187
+ end
188
+
189
+ # Returns a CSS response that hides all elements on the page and
190
+ # displays the exception
191
+ def css_exception_response(exception)
192
+ message = "\n#{exception.class.name}: #{exception.message}"
193
+ backtrace = "\n #{exception.backtrace.first}"
194
+
195
+ body = <<-CSS
196
+ html {
197
+ padding: 18px 36px;
198
+ }
199
+
200
+ head {
201
+ display: block;
202
+ }
203
+
204
+ body {
205
+ margin: 0;
206
+ padding: 0;
207
+ }
208
+
209
+ body > * {
210
+ display: none !important;
211
+ }
212
+
213
+ head:after, body:before, body:after {
214
+ display: block !important;
215
+ }
216
+
217
+ head:after {
218
+ font-family: sans-serif;
219
+ font-size: large;
220
+ font-weight: bold;
221
+ content: "Error compiling CSS asset";
222
+ }
223
+
224
+ body:before, body:after {
225
+ font-family: monospace;
226
+ white-space: pre-wrap;
227
+ }
228
+
229
+ body:before {
230
+ font-weight: bold;
231
+ content: "#{escape_css_content(message)}";
232
+ }
233
+
234
+ body:after {
235
+ content: "#{escape_css_content(backtrace)}";
236
+ }
237
+ CSS
238
+
239
+ [ 200, { "Content-Type" => "text/css; charset=utf-8", "Content-Length" => body.bytesize.to_s }, [ body ] ]
240
+ end
241
+
242
+ # Escape special characters for use inside a CSS content("...") string
243
+ def escape_css_content(content)
244
+ content.
245
+ gsub('\\', '\\\\005c ').
246
+ gsub("\n", '\\\\000a ').
247
+ gsub('"', '\\\\0022 ').
248
+ gsub('/', '\\\\002f ')
249
+ end
250
+
251
+ def cache_headers(env, etag)
252
+ headers = {}
253
+
254
+ # Set caching headers
255
+ headers["Cache-Control"] = String.new("public")
256
+ headers["ETag"] = %("#{etag}")
257
+
258
+ # If the request url contains a fingerprint, set a long
259
+ # expires on the response
260
+ if path_fingerprint(env["PATH_INFO"])
261
+ headers["Cache-Control"] << ", max-age=31536000, immutable"
262
+
263
+ # Otherwise set `must-revalidate` since the asset could be modified.
264
+ else
265
+ headers["Cache-Control"] << ", must-revalidate"
266
+ headers["Vary"] = "Accept-Encoding"
267
+ end
268
+
269
+ headers
270
+ end
271
+
272
+ def headers(env, asset, length)
273
+ headers = {}
274
+
275
+ # Set content length header
276
+ headers["Content-Length"] = length.to_s
277
+
278
+ if asset&.sourcemap
279
+ headers['SourceMap'] = env['SCRIPT_NAME'] + env['PATH_INFO'] + '.map'
280
+ end
281
+
282
+ # Set content type header
283
+ if type = asset.content_type
284
+ # Set charset param for text/* mime types
285
+ if type.start_with?("text/") && asset.charset
286
+ type += "; charset=#{asset.charset}"
287
+ end
288
+ headers["Content-Type"] = type
289
+ end
290
+
291
+ headers.merge(cache_headers(env, asset.etag))
292
+ end
293
+
294
+ # Gets ETag fingerprint.
295
+ #
296
+ # "foo-0aa2105d29558f3eb790d411d7d8fb66.js"
297
+ # # => "0aa2105d29558f3eb790d411d7d8fb66"
298
+ #
299
+ def path_fingerprint(path)
300
+ path[/-([0-9a-f]{7,128})\.[^.]+\z/, 1]
301
+ end
302
+
303
+ def absolute_path?(path)
304
+ path.start_with?(File::SEPARATOR)
305
+ end
306
+ end
307
+ end