assiette 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53dcdd2252625c13acb100e88de0f7a0bdefe7e09ddf560a4505b8134488129a
4
+ data.tar.gz: b564439804f77d5630efee16e9102ba40e10e9d5b29295dff081ea51db921b03
5
+ SHA512:
6
+ metadata.gz: 0da513d9800eb7f4041a2d3ce754d30da6ea05276a2dd868c6d094868a82f37d4040485040463f8885c25cac2f12fdd2c193be1c94b82a9fec46654d36b45c07
7
+ data.tar.gz: fbbceda42225f3dadc09e885909f66d7711593a62e491a80583d1f8a8e856223ecb0e8f2fcf4e714ac42e4d082badba92af003ee4fd1e1438958e2d74887792b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Julik Tarkhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/*_test.rb"
9
+ t.verbose = false
10
+ end
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[test standard]
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Assiette::Engine.routes.draw do
4
+ # No routes — asset serving is handled by Assiette::Server middleware
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Assiette
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Assiette
8
+
9
+ # Inject helpers into all ActionView contexts so host apps
10
+ # get assiette_asset_path() and assiette_modulepreload_tags() for free
11
+ initializer "assiette.helpers" do
12
+ ActiveSupport.on_load(:action_view) do
13
+ include Assiette::Helpers
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assiette
4
+ module Helpers
5
+ # Returns the URL path to an asset served by Assiette, with a cache-busting
6
+ # version tag appended.
7
+ def assiette_asset_path(path)
8
+ entry = request.env["assiette.stack"]&.last
9
+ raise "No Assiette::Server in middleware stack" unless entry
10
+ entry[:server].absolute_asset_url_path(path, entry[:script_name])
11
+ end
12
+
13
+ # Returns the SRI integrity hash for an asset, computed over the served
14
+ # (rewritten) content. Returns nil if the file is not found.
15
+ def assiette_asset_integrity(path)
16
+ entry = request.env["assiette.stack"]&.last
17
+ raise "No Assiette::Server in middleware stack" unless entry
18
+ entry[:server].asset_integrity(path)
19
+ end
20
+
21
+ # Generates a <link rel="stylesheet"> tag with SRI integrity.
22
+ def assiette_stylesheet_tag(path)
23
+ tag.link(rel: "stylesheet", href: assiette_asset_path(path),
24
+ integrity: assiette_asset_integrity(path), crossorigin: "anonymous")
25
+ end
26
+
27
+ # Generates <link rel="modulepreload"> tags for all detected ES modules
28
+ # under the configured asset roots. Each tag includes an SRI integrity
29
+ # hash computed over the served (rewritten) content.
30
+ def assiette_modulepreload_tags
31
+ entry = request.env["assiette.stack"]&.last
32
+ raise "No Assiette::Server in middleware stack" unless entry
33
+ modules = entry[:server].js_modules
34
+ safe_join(modules.map { |mod|
35
+ tag.link(rel: "modulepreload", href: assiette_asset_path(mod[:path]),
36
+ integrity: mod[:integrity], crossorigin: "anonymous")
37
+ }, "\n")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assiette
4
+ module Rewriter
5
+ # Matches quoted strings that look like relative/absolute JS import paths.
6
+ # Handles ./ ../ / but NOT protocol-relative //
7
+ # $1 = quote char, $2 = path including extension
8
+ JS_IMPORT_RE = /(["'])(\.{0,2}\/(?!\/)[^"']*\.(?:js|mjs|es))\1/
9
+
10
+ # Matches url() in CSS with relative/absolute paths.
11
+ # Handles url(./path), url("./path"), url('../path'), url(/path)
12
+ # but NOT url(data:...), url(https://...), url(//...)
13
+ # $1 = opening (quote or empty), $2 = path, $3 = closing (quote or empty)
14
+ CSS_URL_RE = /url\((\s*["']?)(\.{0,2}\/(?!\/)[^)"']*?)(\s*["']?\s*)\)/
15
+
16
+ module_function
17
+
18
+ def rewrite_js_imports(source, version_tag)
19
+ source.gsub(JS_IMPORT_RE) do
20
+ "#{$1}#{$2}?v=#{version_tag}#{$1}"
21
+ end
22
+ end
23
+
24
+ def rewrite_css_urls(source, version_tag)
25
+ source.gsub(CSS_URL_RE) do
26
+ "url(#{$1}#{$2}?v=#{version_tag}#{$3})"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "digest/sha2"
5
+ require "base64"
6
+ require "pathname"
7
+
8
+ module Assiette
9
+ class Server
10
+ CONTENT_TYPES = {
11
+ ".js" => "application/javascript",
12
+ ".mjs" => "application/javascript",
13
+ ".css" => "text/css",
14
+ ".svg" => "image/svg+xml",
15
+ ".png" => "image/png",
16
+ ".ico" => "image/x-icon"
17
+ }.freeze
18
+
19
+ JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
20
+
21
+ CACHE_CONTROL = "public, max-age=432000, must-revalidate"
22
+
23
+ def initialize(app, root:, additional_directory_mappings: {})
24
+ @app = app
25
+ @mappings = build_mappings(root, additional_directory_mappings)
26
+ @integrity_cache = {}
27
+ @integrity_mutex = Mutex.new
28
+ @modules_cache = nil
29
+ @modules_mutex = Mutex.new
30
+ @modules_version = nil
31
+ end
32
+
33
+ def call(env)
34
+ stack = (env["assiette.stack"] ||= [])
35
+ stack << {server: self, script_name: env["SCRIPT_NAME"].to_s}
36
+
37
+ result = serve(env)
38
+ return result if result
39
+
40
+ @app.call(env)
41
+ end
42
+
43
+ # Public API for helpers
44
+ def absolute_asset_url_path(path, script_name = "")
45
+ clean = path.sub(%r{\A/}, "")
46
+ return nil unless resolve_file(clean)
47
+ "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
48
+ end
49
+
50
+ def asset_integrity(path)
51
+ version_tag = Assiette.version_tag
52
+ @integrity_mutex.synchronize do
53
+ if @integrity_version != version_tag
54
+ @integrity_cache = {}
55
+ @integrity_version = version_tag
56
+ end
57
+ return @integrity_cache[path] if @integrity_cache.key?(path)
58
+
59
+ clean = path.sub(%r{\A/}, "")
60
+ @integrity_cache[path] = compute_integrity(clean, version_tag)
61
+ end
62
+ end
63
+
64
+ def js_modules
65
+ version_tag = Assiette.version_tag
66
+ @modules_mutex.synchronize do
67
+ return @modules_cache if @modules_version == version_tag
68
+
69
+ @modules_cache = @mappings.flat_map { |prefix, root|
70
+ Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
71
+ next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
72
+ relative = Pathname.new(abs).relative_path_from(root).to_s
73
+ mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
74
+ {path: mod_path, integrity: asset_integrity(mod_path)}
75
+ }
76
+ }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
77
+
78
+ @modules_version = version_tag
79
+ @modules_cache
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def build_mappings(root, additional_directory_mappings)
86
+ mappings = [["", Pathname.new(root).expand_path]]
87
+ additional_directory_mappings.each do |prefix, path|
88
+ clean_prefix = prefix.to_s.sub(%r{\A/}, "").chomp("/")
89
+ mappings << [clean_prefix, Pathname.new(path).expand_path]
90
+ end
91
+ mappings
92
+ end
93
+
94
+ def serve(env)
95
+ return unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
96
+
97
+ path_info = Rack::Utils.unescape_path(env["PATH_INFO"])
98
+ path_info = path_info.sub(%r{\A/}, "")
99
+
100
+ extension = File.extname(path_info)
101
+ content_type = CONTENT_TYPES[extension]
102
+ return unless content_type
103
+
104
+ file_path = resolve_file(path_info)
105
+ return unless file_path
106
+
107
+ raw_bytes = File.binread(file_path)
108
+
109
+ # ETag from raw bytes for stability
110
+ etag = %("#{Digest::SHA1.hexdigest(raw_bytes)}")
111
+ if env["HTTP_IF_NONE_MATCH"] == etag
112
+ return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
113
+ end
114
+
115
+ query = Rack::Utils.parse_query(env["QUERY_STRING"])
116
+ tag = query["v"].to_s.empty? ? Assiette.version_tag : query["v"]
117
+
118
+ body = if JS_EXTENSIONS.include?(extension)
119
+ Rewriter.rewrite_js_imports(raw_bytes, tag)
120
+ elsif extension == ".css"
121
+ Rewriter.rewrite_css_urls(raw_bytes, tag)
122
+ else
123
+ raw_bytes
124
+ end
125
+
126
+ headers = {
127
+ "content-type" => content_type,
128
+ "content-length" => body.bytesize.to_s,
129
+ "cache-control" => CACHE_CONTROL,
130
+ "etag" => etag
131
+ }
132
+
133
+ [200, headers, [body]]
134
+ end
135
+
136
+ def resolve_file(path)
137
+ clean = path.sub(%r{\A/}, "")
138
+ @mappings.each do |prefix, root|
139
+ if prefix.empty?
140
+ relative = clean
141
+ elsif clean.start_with?(prefix + "/")
142
+ relative = clean[(prefix.length + 1)..]
143
+ elsif clean == prefix
144
+ next
145
+ else
146
+ next
147
+ end
148
+
149
+ abs = root.join(relative).cleanpath
150
+ next unless abs.to_s.start_with?(root.to_s + "/")
151
+ return abs if abs.exist? && abs.file?
152
+ end
153
+ nil
154
+ end
155
+
156
+ def compute_integrity(clean, version_tag)
157
+ file_path = resolve_file(clean)
158
+ return nil unless file_path
159
+
160
+ raw = File.read(file_path)
161
+ ext = File.extname(clean)
162
+ served = case ext
163
+ when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
164
+ when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
165
+ else raw
166
+ end
167
+ "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assiette
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,33 @@
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
data/lib/assiette.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "assiette/version"
4
+ require_relative "assiette/version_tag"
5
+ require_relative "assiette/rewriter"
6
+ require_relative "assiette/server"
7
+ require_relative "assiette/helpers"
8
+ require_relative "assiette/engine"
9
+
10
+ module Assiette
11
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Assiette
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates an Assiette initializer with middleware configuration"
11
+
12
+ def create_initializer
13
+ template "initializer.rb.tt", "config/initializers/assiette.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Assiette serves static assets (JS, CSS, SVG, etc.) from your app with
4
+ # cache headers, SRI integrity hashes, and automatic JS import rewriting.
5
+ #
6
+ # This file runs during :load_config_initializers, which is before the
7
+ # middleware stack is frozen (:build_middleware_stack) in Rails 8.1+.
8
+
9
+ # Serve files from app/assets (JS modules, CSS, SVGs, etc.)
10
+ Rails.application.config.middleware.use(
11
+ Assiette::Server,
12
+ root: Rails.root.join("app/assets")
13
+ )
14
+
15
+ # Serve files from public/ (favicons, static images, etc.)
16
+ Rails.application.config.middleware.use(
17
+ Assiette::Server,
18
+ root: Rails.root.join("public")
19
+ )
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: assiette
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-05-20 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.2'
40
+ description: Serves static assets with cache-busting version tags and on-the-fly JS/CSS
41
+ URL rewriting, without requiring any asset pipeline
42
+ email:
43
+ - me@julik.nl
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - Rakefile
50
+ - config/routes.rb
51
+ - lib/assiette.rb
52
+ - lib/assiette/engine.rb
53
+ - lib/assiette/helpers.rb
54
+ - lib/assiette/rewriter.rb
55
+ - lib/assiette/server.rb
56
+ - lib/assiette/version.rb
57
+ - lib/assiette/version_tag.rb
58
+ - lib/generators/assiette/install/install_generator.rb
59
+ - lib/generators/assiette/install/templates/initializer.rb.tt
60
+ homepage: https://github.com/julik/assiette
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ allowed_push_host: https://rubygems.org
65
+ homepage_uri: https://github.com/julik/assiette
66
+ source_code_uri: https://github.com/julik/assiette
67
+ changelog_uri: https://github.com/julik/assiette/blob/main/CHANGELOG.md
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.1.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.6
83
+ specification_version: 4
84
+ summary: Zero-build asset serving for Rails engines
85
+ test_files: []