assiette 0.3.1 → 0.6.0
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.
- checksums.yaml +4 -4
- data/lib/assiette/asset_handler.rb +172 -51
- data/lib/assiette/controller_etag.rb +38 -0
- data/lib/assiette/dependency_graph.rb +348 -0
- data/lib/assiette/helpers.rb +32 -10
- data/lib/assiette/railtie.rb +6 -0
- data/lib/assiette/rewriter.rb +25 -4
- data/lib/assiette/server.rb +28 -21
- data/lib/assiette/version.rb +1 -1
- data/lib/assiette.rb +2 -0
- metadata +8 -4
- data/lib/assiette/version_tag.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b94d7ebde46d04a55abfd53f02d95c2eb6b4d4df1bda833c98089afbefd1ab1b
|
|
4
|
+
data.tar.gz: 342adfcac7ade1a593af2f887883314bd35d1ccae4e87f91e503c0008e8db46a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26ede00337abc21c2ee238e0e99d7d2143d61cbae84eac72d22210f16cd373c0a41056a9edad881b6a6f049acb353e96605cde191238b6ea643998a4cb255db9
|
|
7
|
+
data.tar.gz: 7c5507d623a0cc264ecbd8f5776a71aef7ad001753d1117c9e0f8cced9fcbc67542a447d4faca3152d89109cea5e2175fc1a72d564aff1cfc22c2f5162e63361
|
|
@@ -1,31 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest/sha1"
|
|
4
|
-
require "digest/sha2"
|
|
5
|
-
require "base64"
|
|
6
3
|
require "pathname"
|
|
7
|
-
|
|
4
|
+
require "digest/sha2"
|
|
8
5
|
|
|
9
6
|
module Assiette
|
|
10
7
|
class AssetHandler
|
|
8
|
+
JS_CONTENT_TYPE = "application/javascript"
|
|
9
|
+
|
|
10
|
+
# The extensions every handler serves. Individual handlers can add to this
|
|
11
|
+
# (or override it) through the `content_types:` argument — see #initialize.
|
|
11
12
|
CONTENT_TYPES = {
|
|
12
|
-
".js" =>
|
|
13
|
-
".mjs" =>
|
|
13
|
+
".js" => JS_CONTENT_TYPE,
|
|
14
|
+
".mjs" => JS_CONTENT_TYPE,
|
|
14
15
|
".css" => "text/css",
|
|
15
16
|
".svg" => "image/svg+xml",
|
|
16
17
|
".png" => "image/png",
|
|
18
|
+
".jpg" => "image/jpeg",
|
|
19
|
+
".jpeg" => "image/jpeg",
|
|
17
20
|
".ico" => "image/x-icon"
|
|
18
21
|
}.freeze
|
|
19
22
|
|
|
20
23
|
JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
attr_reader :dependency_graph
|
|
26
|
+
|
|
27
|
+
# Every extension this handler serves, as ".ext" => content type. This is
|
|
28
|
+
# CONTENT_TYPES with the `content_types:` argument merged over it.
|
|
29
|
+
attr_reader :content_types
|
|
30
|
+
|
|
31
|
+
# `content_types` registers extra extensions on this handler alone, merged
|
|
32
|
+
# over CONTENT_TYPES — so it can also override a default mapping. The
|
|
33
|
+
# defaults themselves are never touched: a handler that was not given an
|
|
34
|
+
# extension keeps refusing it.
|
|
35
|
+
#
|
|
36
|
+
# AssetHandler.new(root: "...", content_types: {".woff2" => "font/woff2"})
|
|
37
|
+
#
|
|
38
|
+
# Extensions are normalized to a leading dot and lowercase, so "woff2",
|
|
39
|
+
# ".woff2" and ".WOFF2" all register the same thing. Lookups and globs are
|
|
40
|
+
# case-insensitive as well, so a file on disk named PHOTO.JPG is served
|
|
41
|
+
# just like photo.jpg.
|
|
42
|
+
def initialize(root:, additional_directory_mappings: {}, content_types: {})
|
|
43
|
+
@content_types = CONTENT_TYPES.merge(content_types.to_h { |ext, type| [normalize_extension(ext), type] }).freeze
|
|
23
44
|
@mappings = build_mappings(root, additional_directory_mappings)
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
@dependency_graph = DependencyGraph.new(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The content type this handler serves `path` as, or nil if its extension
|
|
49
|
+
# is not one this handler knows.
|
|
50
|
+
def content_type_for(path)
|
|
51
|
+
@content_types[normalize_extension(File.extname(path))]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Yields (url_path, abs_path) for every file with a recognized extension.
|
|
55
|
+
def each_mapped_file
|
|
56
|
+
@mappings.each do |prefix, root|
|
|
57
|
+
@content_types.each_key do |ext|
|
|
58
|
+
Dir.glob(File.join(root, "**/*#{case_insensitive(ext)}")).each do |abs|
|
|
59
|
+
relative = Pathname.new(abs).relative_path_from(root).to_s
|
|
60
|
+
url_path = if prefix.empty?
|
|
61
|
+
relative
|
|
62
|
+
else
|
|
63
|
+
"#{prefix}/#{relative}"
|
|
64
|
+
end
|
|
65
|
+
yield url_path, abs
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
29
69
|
end
|
|
30
70
|
|
|
31
71
|
def resolve_file(path)
|
|
@@ -51,44 +91,139 @@ module Assiette
|
|
|
51
91
|
def absolute_asset_url_path(path, script_name = "")
|
|
52
92
|
clean = path.sub(%r{\A/}, "")
|
|
53
93
|
return nil unless resolve_file(clean)
|
|
54
|
-
|
|
94
|
+
hash = @dependency_graph.tree_sha(clean) || "00000000"
|
|
95
|
+
"#{script_name}/#{clean}?s=#{hash}"
|
|
55
96
|
end
|
|
56
97
|
|
|
57
98
|
def asset_integrity(path)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@integrity_cache = {}
|
|
62
|
-
@integrity_version = version_tag
|
|
63
|
-
end
|
|
64
|
-
return @integrity_cache[path] if @integrity_cache.key?(path)
|
|
65
|
-
|
|
66
|
-
clean = path.sub(%r{\A/}, "")
|
|
67
|
-
@integrity_cache[path] = compute_integrity(clean, version_tag)
|
|
68
|
-
end
|
|
99
|
+
clean = path.sub(%r{\A/}, "")
|
|
100
|
+
return nil unless resolve_file(clean)
|
|
101
|
+
@dependency_graph.tree_integrity(clean)
|
|
69
102
|
end
|
|
70
103
|
|
|
71
104
|
def js_modules
|
|
72
|
-
|
|
73
|
-
@
|
|
74
|
-
|
|
105
|
+
js_glob = "**/*{#{javascript_extensions.map { |ext| case_insensitive(ext) }.join(",")}}"
|
|
106
|
+
@mappings.flat_map { |prefix, root|
|
|
107
|
+
Dir.glob(File.join(root, js_glob)).filter_map { |abs|
|
|
108
|
+
next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
|
|
109
|
+
relative = Pathname.new(abs).relative_path_from(root).to_s
|
|
110
|
+
mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
|
|
111
|
+
{path: mod_path, integrity: asset_integrity(mod_path)}
|
|
112
|
+
}
|
|
113
|
+
}.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
|
|
114
|
+
end
|
|
75
115
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
# A single 16-char hex hash covering every asset this handler can serve.
|
|
117
|
+
# Fold it into a page's ETag and the page stops validating as soon as any
|
|
118
|
+
# Assiette URL on it would come out different.
|
|
119
|
+
#
|
|
120
|
+
# This is not a sweep over every mapped file — it hashes the dependency
|
|
121
|
+
# graph's apexes, whose digests already fold in everything they reach.
|
|
122
|
+
def digest
|
|
123
|
+
ensure_graph_populated!
|
|
124
|
+
combined = Digest::SHA256.new
|
|
125
|
+
@dependency_graph.apex_paths.each do |url_path|
|
|
126
|
+
combined << url_path << "\0" << @dependency_graph.tree_sha(url_path).to_s << "\0"
|
|
127
|
+
end
|
|
128
|
+
combined.hexdigest[0, 16]
|
|
129
|
+
end
|
|
84
130
|
|
|
85
|
-
|
|
86
|
-
|
|
131
|
+
# A hash covering exactly the assets named in `url_paths`, for a page that
|
|
132
|
+
# knows its own entry points and wants a validator scoped to them:
|
|
133
|
+
#
|
|
134
|
+
# fresh_when(@post, etag: handler.digest_for(["/application.css", "/js/app.js"]))
|
|
135
|
+
#
|
|
136
|
+
# Naming the nodes is what makes this cheap, and short. Each one's
|
|
137
|
+
# fingerprint already folds in its whole import subtree, so one entry module
|
|
138
|
+
# covers however many files hang off it, and nothing has to be discovered —
|
|
139
|
+
# no glob, no apex set, no populated graph, just one `File.mtime` per named
|
|
140
|
+
# node and per node below it that the graph already holds.
|
|
141
|
+
#
|
|
142
|
+
# An unknown path hashes as the empty string, so a page keeps busting when
|
|
143
|
+
# an asset it links is deleted or renamed away. What this cannot cover is a
|
|
144
|
+
# page that renders a listing of whatever the handler holds — the
|
|
145
|
+
# modulepreload tags — because that depends on which files exist, not only
|
|
146
|
+
# on the ones named here. Use #digest for those.
|
|
147
|
+
def digest_for(url_paths)
|
|
148
|
+
combined = Digest::SHA256.new
|
|
149
|
+
url_paths.map { |path| path.sub(%r{\A/}, "") }.uniq.sort.each do |url_path|
|
|
150
|
+
combined << url_path << "\0" << @dependency_graph.tree_sha(url_path).to_s << "\0"
|
|
87
151
|
end
|
|
152
|
+
combined.hexdigest[0, 16]
|
|
88
153
|
end
|
|
89
154
|
|
|
90
155
|
private
|
|
91
156
|
|
|
157
|
+
# The extensions served as JavaScript: .js and .mjs, plus anything this
|
|
158
|
+
# handler had registered with a JavaScript content type.
|
|
159
|
+
def javascript_extensions
|
|
160
|
+
@content_types.select { |_ext, content_type| content_type == JS_CONTENT_TYPE }.keys
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Dir.glob ignores File::FNM_CASEFOLD - "Case sensitivity depends on your
|
|
164
|
+
# system" - so on Linux a "*.jpg" pattern walks straight past PHOTO.JPG.
|
|
165
|
+
# Fold the case into the pattern itself instead: ".jpg" => ".[jJ][pP][gG]".
|
|
166
|
+
def case_insensitive(extension)
|
|
167
|
+
extension.gsub(/[a-z]/) { |char| "[#{char}#{char.upcase}]" }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ".JPG", "jpg" and ".jpg" all name the same extension.
|
|
171
|
+
def normalize_extension(extension)
|
|
172
|
+
ext = extension.to_s.downcase
|
|
173
|
+
ext.start_with?(".") ? ext : ".#{ext}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The apex set is only meaningful over a fully populated graph, and the
|
|
177
|
+
# graph is lazy — asking it opportunistically gives different answers
|
|
178
|
+
# depending on what has been requested so far, which would have two Puma
|
|
179
|
+
# workers computing different ETags for identical content.
|
|
180
|
+
#
|
|
181
|
+
# The walk is amortised behind a directory-mtime guard rather than a glob
|
|
182
|
+
# per call. Directory mtimes move when an entry is added, removed or
|
|
183
|
+
# renamed — exactly the events that change the file list. Editing a file in
|
|
184
|
+
# place does not move them, and does not need to: the graph checks per-file
|
|
185
|
+
# mtimes on every access.
|
|
186
|
+
def ensure_graph_populated!
|
|
187
|
+
return if watched_directories_unchanged?
|
|
188
|
+
each_mapped_file { |url_path, _abs_path| @dependency_graph[url_path] }
|
|
189
|
+
@dependency_graph.prune_deleted!
|
|
190
|
+
@watched_directories = directory_mtimes
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def watched_directories_unchanged?
|
|
194
|
+
return false unless @watched_directories
|
|
195
|
+
@watched_directories.all? { |dir, mtime| File.mtime(dir) == mtime }
|
|
196
|
+
rescue Errno::ENOENT
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Every directory under every mapped root, with its mtime.
|
|
201
|
+
#
|
|
202
|
+
# Watching only the directories that held a servable file leaves a hole
|
|
203
|
+
# right where it hurts. Adding app/assets/js/new_thing/a.js moves the mtime
|
|
204
|
+
# of app/assets/js — but if that directory holds no servable file of its
|
|
205
|
+
# own it was never watched, and the new asset stays invisible to the
|
|
206
|
+
# digest: every page keeps validating while linking HTML that has no idea
|
|
207
|
+
# the file exists. Watching the intermediate directories too costs one more
|
|
208
|
+
# `stat` apiece per call and closes it.
|
|
209
|
+
#
|
|
210
|
+
# nil rather than an empty Hash when there is nothing to watch, because an
|
|
211
|
+
# empty Hash reads as "nothing changed" forever — a root that does not
|
|
212
|
+
# exist yet would never be picked up once it did.
|
|
213
|
+
def directory_mtimes
|
|
214
|
+
mtimes = {}
|
|
215
|
+
@mappings.each do |_prefix, root|
|
|
216
|
+
next unless root.directory?
|
|
217
|
+
[root.to_s, *Dir.glob(File.join(root, "**/"))].each do |dir|
|
|
218
|
+
path = dir.chomp("/")
|
|
219
|
+
mtimes[path] ||= File.mtime(path)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
mtimes.empty? ? nil : mtimes
|
|
223
|
+
rescue Errno::ENOENT
|
|
224
|
+
nil # something vanished mid-walk; re-walk on the next call
|
|
225
|
+
end
|
|
226
|
+
|
|
92
227
|
def build_mappings(root, additional_directory_mappings)
|
|
93
228
|
mappings = [["", Pathname.new(root).expand_path]]
|
|
94
229
|
additional_directory_mappings.each do |prefix, path|
|
|
@@ -97,19 +232,5 @@ module Assiette
|
|
|
97
232
|
end
|
|
98
233
|
mappings
|
|
99
234
|
end
|
|
100
|
-
|
|
101
|
-
def compute_integrity(clean, version_tag)
|
|
102
|
-
file_path = resolve_file(clean)
|
|
103
|
-
return nil unless file_path
|
|
104
|
-
|
|
105
|
-
raw = File.read(file_path)
|
|
106
|
-
ext = File.extname(clean)
|
|
107
|
-
served = case ext
|
|
108
|
-
when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
|
|
109
|
-
when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
|
|
110
|
-
else raw
|
|
111
|
-
end
|
|
112
|
-
"sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
|
|
113
|
-
end
|
|
114
235
|
end
|
|
115
236
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Assiette
|
|
4
|
+
# Extends ActionController::Base with a one-line macro that folds the
|
|
5
|
+
# Assiette apex digest into every ETag the controller generates.
|
|
6
|
+
#
|
|
7
|
+
# class ApplicationController < ActionController::Base
|
|
8
|
+
# include_assiette_etags!
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Without this, a page whose ETag is built from the code revision plus model
|
|
12
|
+
# cache keys keeps validating after you edit a stylesheet — neither of those
|
|
13
|
+
# moved — and a caching layer happily serves stored HTML linking the previous
|
|
14
|
+
# ?s= hash.
|
|
15
|
+
#
|
|
16
|
+
# The value folded in covers every asset the request's handlers can serve,
|
|
17
|
+
# rather than the ones this particular page happens to link. That is the
|
|
18
|
+
# coarse answer, and it is the only one available before the template runs:
|
|
19
|
+
# `etag` blocks are evaluated inside `fresh_when`, in the action, and skipping
|
|
20
|
+
# the render is the entire point of a conditional GET. A page that wants a
|
|
21
|
+
# narrower validator can name its entry points and pass
|
|
22
|
+
# AssetHandler#digest_for to fresh_when itself.
|
|
23
|
+
module ControllerEtag
|
|
24
|
+
def include_assiette_etags!
|
|
25
|
+
etag do
|
|
26
|
+
# Every handler that saw the request, not only the innermost one. The
|
|
27
|
+
# view helpers resolve an asset against the whole stack, so a page can
|
|
28
|
+
# link assets an outer handler serves — and a change to one of those
|
|
29
|
+
# has to move this page's ETag just the same.
|
|
30
|
+
#
|
|
31
|
+
# Resolved off the Rack env rather than Rails.application.assets so
|
|
32
|
+
# this works in mode 1 too.
|
|
33
|
+
stack = request.env["assiette.stack"]
|
|
34
|
+
stack.map { |entry| entry[:handler].digest }.join("/") if stack&.any?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Assiette
|
|
8
|
+
class DependencyGraph
|
|
9
|
+
# Per-file node in the dependency graph.
|
|
10
|
+
class Asset
|
|
11
|
+
attr_reader :url_path
|
|
12
|
+
attr_accessor :abs_path, :mtime, :digest, :deps, :dependents
|
|
13
|
+
|
|
14
|
+
def initialize(url_path:, abs_path:, mtime:)
|
|
15
|
+
@url_path = url_path.freeze
|
|
16
|
+
@abs_path = abs_path
|
|
17
|
+
@mtime = mtime
|
|
18
|
+
@digest = nil
|
|
19
|
+
@deps = []
|
|
20
|
+
@dependents = Set.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 8-char hex hash for ?s= cache-busting query params.
|
|
24
|
+
def checksum_tag
|
|
25
|
+
digest&.unpack1("H8")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# SRI integrity string for integrity= attributes.
|
|
29
|
+
def sri_integrity
|
|
30
|
+
return nil unless digest
|
|
31
|
+
"sha256-#{Base64.strict_encode64(digest)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether this asset's file has changed since last scan.
|
|
35
|
+
def stale?
|
|
36
|
+
File.mtime(abs_path) != mtime
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether the file no longer exists on disk.
|
|
42
|
+
def deleted?
|
|
43
|
+
!File.exist?(abs_path.to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(handler)
|
|
48
|
+
@handler = handler
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@assets = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the Asset for a url_path, or nil.
|
|
54
|
+
def [](url_path)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
ensure_asset!(url_path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the 8-char hex content hash for a URL path.
|
|
61
|
+
def tree_sha(url_path)
|
|
62
|
+
self[url_path]&.checksum_tag
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the SRI integrity string for a URL path.
|
|
66
|
+
def tree_integrity(url_path)
|
|
67
|
+
self[url_path]&.sri_integrity
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolves a relative import path to a URL path.
|
|
71
|
+
def resolve_import_for(from_url_path, relative_path)
|
|
72
|
+
if relative_path.start_with?("/")
|
|
73
|
+
relative_path.sub(%r{\A/}, "")
|
|
74
|
+
else
|
|
75
|
+
dir = File.dirname(from_url_path)
|
|
76
|
+
File.expand_path(relative_path, "/#{dir}").sub(%r{\A/}, "")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Rewrites content with per-dependency hashes. Thread-safe.
|
|
81
|
+
def rewrite_content(url_path, raw_content)
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
ensure_asset!(url_path)
|
|
84
|
+
rewrite_content_internal(url_path, raw_content)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# URL paths of the graph's apexes — assets nothing else imports or
|
|
89
|
+
# references. Every apex's digest already folds in its entire dependency
|
|
90
|
+
# subtree, so hashing the apexes hashes the whole graph with each file
|
|
91
|
+
# counted exactly once.
|
|
92
|
+
#
|
|
93
|
+
# A cycle that nothing points into has no member with empty dependents, so
|
|
94
|
+
# it would silently drop out. Rather than keeping SCC bookkeeping around
|
|
95
|
+
# (it is discarded once resolution finishes), walk down from the apexes and
|
|
96
|
+
# adopt whatever was never reached.
|
|
97
|
+
def apex_paths
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
apexes = @assets.each_value.select { |asset| asset.dependents.empty? }.map(&:url_path)
|
|
100
|
+
reached = Set.new
|
|
101
|
+
queue = apexes.dup
|
|
102
|
+
while (url_path = queue.shift)
|
|
103
|
+
next unless reached.add?(url_path)
|
|
104
|
+
queue.concat(@assets[url_path]&.deps || [])
|
|
105
|
+
end
|
|
106
|
+
(apexes + (@assets.keys - reached.to_a)).sort
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Drops nodes whose files have disappeared from disk. Assets that something
|
|
111
|
+
# still imports get pruned as a side effect of resolving their dependents,
|
|
112
|
+
# but an orphan that nobody points at is never revisited and would linger.
|
|
113
|
+
def prune_deleted!
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
@assets.each_value.select(&:deleted?).each { |asset| remove_asset!(asset.url_path) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Forces a full rebuild on next access.
|
|
120
|
+
def invalidate!
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
@assets = {}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Lazily resolve a single asset and its transitive dependencies.
|
|
129
|
+
# Returns the Asset or nil if the file doesn't exist.
|
|
130
|
+
def ensure_asset!(url_path)
|
|
131
|
+
@resolving = Set.new
|
|
132
|
+
@checked = Set.new
|
|
133
|
+
@cycle_groups = []
|
|
134
|
+
asset = resolve_asset!(url_path)
|
|
135
|
+
# Process any cycles detected during resolution
|
|
136
|
+
@cycle_groups.each { |scc| compute_cycle_digests(scc) }
|
|
137
|
+
@resolving = nil
|
|
138
|
+
@checked = nil
|
|
139
|
+
@cycle_groups = nil
|
|
140
|
+
asset
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Recursive resolution. Detects cycles via @resolving set.
|
|
144
|
+
# @checked prevents re-verifying the same asset within one ensure_asset! call.
|
|
145
|
+
# Returns the Asset or nil. Also returns whether anything changed downstream.
|
|
146
|
+
def resolve_asset!(url_path)
|
|
147
|
+
# Already verified fresh in this ensure_asset! call
|
|
148
|
+
return @assets[url_path] if @checked.include?(url_path)
|
|
149
|
+
|
|
150
|
+
existing = @assets[url_path]
|
|
151
|
+
if existing
|
|
152
|
+
if existing.deleted?
|
|
153
|
+
remove_asset!(url_path)
|
|
154
|
+
return nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if existing.stale?
|
|
158
|
+
rescan_asset!(existing)
|
|
159
|
+
@checked << url_path
|
|
160
|
+
return existing
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Not stale itself — but deps might be. Check them recursively.
|
|
164
|
+
@checked << url_path
|
|
165
|
+
old_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
|
|
166
|
+
existing.deps.each { |dp| resolve_asset!(dp) }
|
|
167
|
+
new_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
|
|
168
|
+
|
|
169
|
+
if old_dep_digests != new_dep_digests
|
|
170
|
+
compute_digest_for(url_path)
|
|
171
|
+
# And tell whoever imports this one, exactly as rescan_asset! does.
|
|
172
|
+
# Without it a node recomputed here keeps stale dependents: they
|
|
173
|
+
# compare their deps' digests from before and after their own visit,
|
|
174
|
+
# and a dep already updated during an earlier visit looks unchanged.
|
|
175
|
+
# Their rewritten content carries the new ?s= while their own
|
|
176
|
+
# fingerprint still says otherwise, so nobody refetches them.
|
|
177
|
+
propagate_to_dependents!(existing)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
return existing
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Resolve url_path to an absolute path
|
|
184
|
+
abs_path = @handler.resolve_file(url_path)
|
|
185
|
+
return nil unless abs_path
|
|
186
|
+
|
|
187
|
+
# Cycle detection
|
|
188
|
+
unless @resolving.add?(url_path)
|
|
189
|
+
return @assets[url_path]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Create the asset node
|
|
193
|
+
asset = @assets[url_path] = Asset.new(
|
|
194
|
+
url_path: url_path,
|
|
195
|
+
abs_path: abs_path,
|
|
196
|
+
mtime: File.mtime(abs_path)
|
|
197
|
+
)
|
|
198
|
+
asset.deps = parse_deps(url_path, File.read(abs_path))
|
|
199
|
+
|
|
200
|
+
# Recursively resolve dependencies
|
|
201
|
+
in_cycle = false
|
|
202
|
+
asset.deps.each do |dep_path|
|
|
203
|
+
if @resolving.include?(dep_path) && !@assets[dep_path]&.digest
|
|
204
|
+
in_cycle = true
|
|
205
|
+
end
|
|
206
|
+
dep = resolve_asset!(dep_path)
|
|
207
|
+
dep.dependents << url_path if dep
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if in_cycle || asset.deps.any? { |dp| @resolving.include?(dp) && !@assets[dp]&.digest }
|
|
211
|
+
scc = collect_cycle(url_path)
|
|
212
|
+
@cycle_groups << scc unless scc.empty?
|
|
213
|
+
else
|
|
214
|
+
compute_digest_for(url_path)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@checked << url_path
|
|
218
|
+
@resolving.delete(url_path)
|
|
219
|
+
asset
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Collect strongly connected component members starting from url_path.
|
|
223
|
+
def collect_cycle(url_path)
|
|
224
|
+
visited = Set.new
|
|
225
|
+
stack = [url_path]
|
|
226
|
+
members = Set.new
|
|
227
|
+
|
|
228
|
+
while (node = stack.pop)
|
|
229
|
+
next unless visited.add?(node)
|
|
230
|
+
asset = @assets[node]
|
|
231
|
+
next unless asset
|
|
232
|
+
members << node if @resolving.include?(node)
|
|
233
|
+
asset.deps.each do |dep|
|
|
234
|
+
stack << dep if @resolving.include?(dep)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
members.to_a
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
|
|
242
|
+
def rescan_asset!(asset)
|
|
243
|
+
url_path = asset.url_path
|
|
244
|
+
asset.mtime = File.mtime(asset.abs_path)
|
|
245
|
+
|
|
246
|
+
# Remove old reverse links
|
|
247
|
+
asset.deps.each do |dep_path|
|
|
248
|
+
dep = @assets[dep_path]
|
|
249
|
+
dep&.dependents&.delete(url_path)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Re-parse imports
|
|
253
|
+
raw = File.read(asset.abs_path)
|
|
254
|
+
asset.deps = parse_deps(url_path, raw)
|
|
255
|
+
|
|
256
|
+
# Recursively ensure deps are fresh
|
|
257
|
+
asset.deps.each do |dep_path|
|
|
258
|
+
dep = resolve_asset!(dep_path)
|
|
259
|
+
dep.dependents << url_path if dep
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Recompute digest
|
|
263
|
+
compute_digest_for(url_path)
|
|
264
|
+
|
|
265
|
+
# Propagate to dependents already in the graph
|
|
266
|
+
propagate_to_dependents!(asset)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Recompute digests for all transitive dependents of an asset.
|
|
270
|
+
def propagate_to_dependents!(asset)
|
|
271
|
+
queue = asset.dependents.to_a
|
|
272
|
+
visited = Set.new
|
|
273
|
+
while (dep_url = queue.shift)
|
|
274
|
+
next unless visited.add?(dep_url)
|
|
275
|
+
dep_asset = @assets[dep_url]
|
|
276
|
+
next unless dep_asset
|
|
277
|
+
compute_digest_for(dep_url)
|
|
278
|
+
queue.concat(dep_asset.dependents.to_a)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def parse_deps(url_path, raw)
|
|
283
|
+
ext = File.extname(url_path)
|
|
284
|
+
import_paths = case ext
|
|
285
|
+
when ".js", ".mjs", ".es" then Rewriter.extract_js_imports(raw)
|
|
286
|
+
when ".css" then Rewriter.extract_css_urls(raw)
|
|
287
|
+
else []
|
|
288
|
+
end
|
|
289
|
+
import_paths.map { |p| resolve_import_for(url_path, p) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def compute_digest_for(url_path)
|
|
293
|
+
asset = @assets[url_path]
|
|
294
|
+
return unless asset
|
|
295
|
+
|
|
296
|
+
raw = File.read(asset.abs_path)
|
|
297
|
+
if asset.deps.empty?
|
|
298
|
+
asset.digest = Digest::SHA256.digest(raw)
|
|
299
|
+
else
|
|
300
|
+
rewritten = rewrite_content_internal(url_path, raw)
|
|
301
|
+
asset.digest = Digest::SHA256.digest(rewritten)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# For cyclic dependencies: hash all members' raw contents together.
|
|
306
|
+
def compute_cycle_digests(scc)
|
|
307
|
+
combined = scc.sort.filter_map { |url_path|
|
|
308
|
+
asset = @assets[url_path]
|
|
309
|
+
next unless asset
|
|
310
|
+
File.read(asset.abs_path)
|
|
311
|
+
}.join("\0")
|
|
312
|
+
|
|
313
|
+
digest = Digest::SHA256.digest(combined)
|
|
314
|
+
scc.each do |url_path|
|
|
315
|
+
asset = @assets[url_path]
|
|
316
|
+
asset.digest = digest if asset
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def rewrite_content_internal(url_path, raw_content)
|
|
321
|
+
ext = File.extname(url_path)
|
|
322
|
+
case ext
|
|
323
|
+
when ".js", ".mjs", ".es"
|
|
324
|
+
Rewriter.rewrite_js_imports(raw_content) do |import_path|
|
|
325
|
+
resolved = resolve_import_for(url_path, import_path)
|
|
326
|
+
@assets[resolved]&.checksum_tag || "00000000"
|
|
327
|
+
end
|
|
328
|
+
when ".css"
|
|
329
|
+
Rewriter.rewrite_css_urls(raw_content) do |ref_path|
|
|
330
|
+
resolved = resolve_import_for(url_path, ref_path)
|
|
331
|
+
@assets[resolved]&.checksum_tag || "00000000"
|
|
332
|
+
end
|
|
333
|
+
else
|
|
334
|
+
raw_content
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def remove_asset!(url_path)
|
|
339
|
+
asset = @assets.delete(url_path)
|
|
340
|
+
return unless asset
|
|
341
|
+
|
|
342
|
+
asset.deps.each do |dep_path|
|
|
343
|
+
dep = @assets[dep_path]
|
|
344
|
+
dep&.dependents&.delete(url_path)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
data/lib/assiette/helpers.rb
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
module Assiette
|
|
4
4
|
module Helpers
|
|
5
5
|
# Returns the URL path to an asset served by Assiette, with a cache-busting
|
|
6
|
-
# version tag appended.
|
|
6
|
+
# version tag appended. Returns nil if no handler in the stack has the file.
|
|
7
7
|
def assiette_asset_path(path)
|
|
8
|
-
entry =
|
|
9
|
-
|
|
8
|
+
entry = assiette_entry_resolving(path)
|
|
9
|
+
return unless entry
|
|
10
10
|
entry[:handler].absolute_asset_url_path(path, entry[:script_name])
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# Returns the SRI integrity hash for an asset, computed over the served
|
|
14
14
|
# (rewritten) content. Returns nil if the file is not found.
|
|
15
15
|
def assiette_asset_integrity(path)
|
|
16
|
-
entry =
|
|
17
|
-
|
|
16
|
+
entry = assiette_entry_resolving(path)
|
|
17
|
+
return unless entry
|
|
18
18
|
entry[:handler].asset_integrity(path)
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -27,14 +27,36 @@ module Assiette
|
|
|
27
27
|
# Generates <link rel="modulepreload"> tags for all detected ES modules
|
|
28
28
|
# under the configured asset roots. Each tag includes an SRI integrity
|
|
29
29
|
# hash computed over the served (rewritten) content.
|
|
30
|
+
#
|
|
31
|
+
# Unlike the path helpers this stays scoped to a single handler — the
|
|
32
|
+
# innermost Server in the stack — on purpose. It renders a listing of
|
|
33
|
+
# everything a handler holds, so walking the stack would put one tenant's
|
|
34
|
+
# file list on another tenant's page.
|
|
30
35
|
def assiette_modulepreload_tags
|
|
31
|
-
entry =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
entry = assiette_stack.last
|
|
37
|
+
handler = entry[:handler]
|
|
38
|
+
safe_join(handler.js_modules.map { |mod|
|
|
39
|
+
tag.link(rel: "modulepreload",
|
|
40
|
+
href: handler.absolute_asset_url_path(mod[:path], entry[:script_name]),
|
|
36
41
|
integrity: mod[:integrity], crossorigin: "anonymous")
|
|
37
42
|
}, "\n")
|
|
38
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def assiette_stack
|
|
48
|
+
stack = request.env["assiette.stack"]
|
|
49
|
+
raise "No Assiette::Server in middleware stack" if stack.nil? || stack.empty?
|
|
50
|
+
stack
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Walks the stack from the innermost Server outwards and returns the first
|
|
54
|
+
# entry whose handler actually has the file. With one Server mounted that is
|
|
55
|
+
# simply the only entry; with several — an app serving a per-tenant
|
|
56
|
+
# directory next to a shared one — the innermost is not necessarily the one
|
|
57
|
+
# holding the asset a page asks for. Returns nil if none of them has it.
|
|
58
|
+
def assiette_entry_resolving(path)
|
|
59
|
+
assiette_stack.reverse_each.find { |entry| entry[:handler].resolve_file(path) }
|
|
60
|
+
end
|
|
39
61
|
end
|
|
40
62
|
end
|
data/lib/assiette/railtie.rb
CHANGED
data/lib/assiette/rewriter.rb
CHANGED
|
@@ -15,16 +15,37 @@ module Assiette
|
|
|
15
15
|
|
|
16
16
|
module_function
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Rewrites JS imports with per-import hashes via a block.
|
|
19
|
+
# The block receives the import path and must return the hash for that import.
|
|
20
|
+
def rewrite_js_imports(source, &block)
|
|
19
21
|
source.gsub(JS_IMPORT_RE) do
|
|
20
|
-
|
|
22
|
+
quote = $1
|
|
23
|
+
path = $2
|
|
24
|
+
hash = yield(path)
|
|
25
|
+
"#{quote}#{path}?s=#{hash}#{quote}"
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
# Rewrites CSS url() references with per-url hashes via a block.
|
|
30
|
+
# The block receives the url path and must return the hash for that url.
|
|
31
|
+
def rewrite_css_urls(source, &block)
|
|
25
32
|
source.gsub(CSS_URL_RE) do
|
|
26
|
-
|
|
33
|
+
open = $1
|
|
34
|
+
path = $2
|
|
35
|
+
close = $3
|
|
36
|
+
hash = yield(path)
|
|
37
|
+
"url(#{open}#{path}?s=#{hash}#{close})"
|
|
27
38
|
end
|
|
28
39
|
end
|
|
40
|
+
|
|
41
|
+
# Returns an array of import paths found in JS source.
|
|
42
|
+
def extract_js_imports(source)
|
|
43
|
+
source.scan(JS_IMPORT_RE).map { |m| m[1] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns an array of url() paths found in CSS source.
|
|
47
|
+
def extract_css_urls(source)
|
|
48
|
+
source.scan(CSS_URL_RE).map { |m| m[1] }
|
|
49
|
+
end
|
|
29
50
|
end
|
|
30
51
|
end
|
data/lib/assiette/server.rb
CHANGED
|
@@ -4,19 +4,30 @@ module Assiette
|
|
|
4
4
|
class Server
|
|
5
5
|
CACHE_CONTROL = "public, max-age=432000, must-revalidate"
|
|
6
6
|
|
|
7
|
-
# Accepts
|
|
7
|
+
# Accepts a pre-built handler, something callable returning a handler for
|
|
8
|
+
# the request at hand, or keyword args:
|
|
8
9
|
# Server.new(app, handler)
|
|
10
|
+
# Server.new(app, ->(env) { Tenant.for(env)&.assiette_handler })
|
|
9
11
|
# Server.new(app, root: "...", additional_directory_mappings: {})
|
|
12
|
+
#
|
|
13
|
+
# The callable form is for applications whose served locations vary per
|
|
14
|
+
# request — a multi-tenant app hands every tenant its own AssetHandler
|
|
15
|
+
# rooted in that tenant's directory. It is called once per request with the
|
|
16
|
+
# Rack env and may return nil, meaning "nothing to serve here": the request
|
|
17
|
+
# then passes straight through, with no entry added to the handler stack.
|
|
10
18
|
def initialize(app, handler = nil, root: nil, additional_directory_mappings: {})
|
|
11
19
|
@app = app
|
|
12
20
|
@handler = handler || AssetHandler.new(root: root, additional_directory_mappings: additional_directory_mappings)
|
|
13
21
|
end
|
|
14
22
|
|
|
15
23
|
def call(env)
|
|
24
|
+
handler = resolve_handler(env)
|
|
25
|
+
return @app.call(env) unless handler
|
|
26
|
+
|
|
16
27
|
stack = (env["assiette.stack"] ||= [])
|
|
17
|
-
stack << {handler:
|
|
28
|
+
stack << {handler: handler, script_name: env["SCRIPT_NAME"].to_s}
|
|
18
29
|
|
|
19
|
-
result = serve(env)
|
|
30
|
+
result = serve(env, handler)
|
|
20
31
|
return result if result
|
|
21
32
|
|
|
22
33
|
@app.call(env)
|
|
@@ -24,37 +35,33 @@ module Assiette
|
|
|
24
35
|
|
|
25
36
|
private
|
|
26
37
|
|
|
27
|
-
def
|
|
38
|
+
def resolve_handler(env)
|
|
39
|
+
@handler.respond_to?(:call) ? @handler.call(env) : @handler
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def serve(env, handler)
|
|
28
43
|
return unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
|
|
29
44
|
|
|
30
45
|
path_info = Rack::Utils.unescape_path(env["PATH_INFO"])
|
|
31
46
|
path_info = path_info.sub(%r{\A/}, "")
|
|
32
47
|
|
|
33
|
-
|
|
34
|
-
content_type = AssetHandler::CONTENT_TYPES[extension]
|
|
48
|
+
content_type = handler.content_type_for(path_info)
|
|
35
49
|
return unless content_type
|
|
36
50
|
|
|
37
|
-
file_path =
|
|
51
|
+
file_path = handler.resolve_file(path_info)
|
|
38
52
|
return unless file_path
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
54
|
+
# Use the dependency graph's content hash for the ETag — it reflects
|
|
55
|
+
# the file's own content plus all its transitive dependencies, and is
|
|
56
|
+
# already computed as part of serving. This lets us short-circuit with
|
|
57
|
+
# a 304 before reading the file for rewriting.
|
|
58
|
+
etag = %("#{handler.dependency_graph.tree_sha(path_info) || "0"}")
|
|
44
59
|
if env["HTTP_IF_NONE_MATCH"] == etag
|
|
45
60
|
return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
|
|
46
61
|
end
|
|
47
62
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
body = if AssetHandler::JS_EXTENSIONS.include?(extension)
|
|
52
|
-
Rewriter.rewrite_js_imports(raw_bytes, tag)
|
|
53
|
-
elsif extension == ".css"
|
|
54
|
-
Rewriter.rewrite_css_urls(raw_bytes, tag)
|
|
55
|
-
else
|
|
56
|
-
raw_bytes
|
|
57
|
-
end
|
|
63
|
+
raw_bytes = File.binread(file_path)
|
|
64
|
+
body = handler.dependency_graph.rewrite_content(path_info, raw_bytes)
|
|
58
65
|
|
|
59
66
|
headers = {
|
|
60
67
|
"content-type" => content_type,
|
data/lib/assiette/version.rb
CHANGED
data/lib/assiette.rb
CHANGED
|
@@ -5,9 +5,11 @@ require_relative "assiette/version"
|
|
|
5
5
|
module Assiette
|
|
6
6
|
autoload :Rewriter, File.expand_path("assiette/rewriter", __dir__)
|
|
7
7
|
autoload :AssetHandler, File.expand_path("assiette/asset_handler", __dir__)
|
|
8
|
+
autoload :DependencyGraph, File.expand_path("assiette/dependency_graph", __dir__)
|
|
8
9
|
autoload :Server, File.expand_path("assiette/server", __dir__)
|
|
9
10
|
autoload :Helpers, File.expand_path("assiette/helpers", __dir__)
|
|
10
11
|
autoload :RailsAssetUrlHelper, File.expand_path("assiette/rails_asset_url_helper", __dir__)
|
|
12
|
+
autoload :ControllerEtag, File.expand_path("assiette/controller_etag", __dir__)
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
require_relative "assiette/railtie" if defined?(Rails::Railtie)
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: assiette
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-13 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: actionpack
|
|
@@ -35,13 +36,14 @@ files:
|
|
|
35
36
|
- Rakefile
|
|
36
37
|
- lib/assiette.rb
|
|
37
38
|
- lib/assiette/asset_handler.rb
|
|
39
|
+
- lib/assiette/controller_etag.rb
|
|
40
|
+
- lib/assiette/dependency_graph.rb
|
|
38
41
|
- lib/assiette/helpers.rb
|
|
39
42
|
- lib/assiette/rails_asset_url_helper.rb
|
|
40
43
|
- lib/assiette/railtie.rb
|
|
41
44
|
- lib/assiette/rewriter.rb
|
|
42
45
|
- lib/assiette/server.rb
|
|
43
46
|
- lib/assiette/version.rb
|
|
44
|
-
- lib/assiette/version_tag.rb
|
|
45
47
|
- lib/generators/assiette/install/install_generator.rb
|
|
46
48
|
- lib/generators/assiette/install/templates/initializer.rb.tt
|
|
47
49
|
homepage: https://github.com/julik/assiette
|
|
@@ -52,6 +54,7 @@ metadata:
|
|
|
52
54
|
homepage_uri: https://github.com/julik/assiette
|
|
53
55
|
source_code_uri: https://github.com/julik/assiette
|
|
54
56
|
changelog_uri: https://github.com/julik/assiette/blob/main/CHANGELOG.md
|
|
57
|
+
post_install_message:
|
|
55
58
|
rdoc_options: []
|
|
56
59
|
require_paths:
|
|
57
60
|
- lib
|
|
@@ -66,7 +69,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
66
69
|
- !ruby/object:Gem::Version
|
|
67
70
|
version: '0'
|
|
68
71
|
requirements: []
|
|
69
|
-
rubygems_version: 3.
|
|
72
|
+
rubygems_version: 3.4.10
|
|
73
|
+
signing_key:
|
|
70
74
|
specification_version: 4
|
|
71
75
|
summary: Zero-build asset serving for Rails engines
|
|
72
76
|
test_files: []
|
data/lib/assiette/version_tag.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest/sha1"
|
|
4
|
-
|
|
5
|
-
module Assiette
|
|
6
|
-
# Computes a short version tag for cache busting.
|
|
7
|
-
# In development: timestamp for instant invalidation on each request
|
|
8
|
-
# In production: derived from APP_REVISION env var or Gemfile.lock digest
|
|
9
|
-
def self.version_tag
|
|
10
|
-
@version_tag ||= compute_version_tag
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def self.reset_version_tag!
|
|
14
|
-
@version_tag = nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def self.compute_version_tag
|
|
18
|
-
if Rails.env.development?
|
|
19
|
-
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
20
|
-
elsif (app_revision = ENV["APP_REVISION"]).present?
|
|
21
|
-
Digest::SHA1.hexdigest(app_revision)[0, 4]
|
|
22
|
-
else
|
|
23
|
-
gemfile_lock_path = Rails.root.join("Gemfile.lock")
|
|
24
|
-
if gemfile_lock_path.exist?
|
|
25
|
-
Digest::SHA1.file(gemfile_lock_path).hexdigest[0, 4]
|
|
26
|
-
else
|
|
27
|
-
(Time.now.utc.to_i / 300).to_s(16)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private_class_method :compute_version_tag
|
|
33
|
-
end
|