condenser 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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