Ruby on Rails Module 4 – Views, forms and assets
Assets: Propshaft, import maps and CSS
How Rails 8.1 names, serves and caches CSS and JavaScript with Propshaft and import maps, what bin/importmap audit checks, and when to add a bundler.
What you will learn
- Explain how Propshaft names, serves and caches the files under app/assets
- Read an import map and add a vendored pin that bin/importmap audit can check
- Predict when a cached page keeps old JavaScript, and fix it with stale_when_importmap_changes
- Configure a nonce-based Content Security Policy that lets the import-map scripts run
- Choose when to switch to jsbundling-rails, cssbundling-rails or tailwindcss-rails
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how Propshaft names, serves and caches the files under app/assets
- Read an import map and add a vendored pin that bin/importmap audit can check
- Predict when a cached page keeps old JavaScript, and fix it with stale_when_importmap_changes
- Configure a nonce-based Content Security Policy that lets the import-map scripts run
- Choose when to switch to jsbundling-rails, cssbundling-rails or tailwindcss-rails
Before you start
On this page
A browser may keep a stylesheet for a year, yet the morning after you change one rule every visitor must see the new
one. A new Rails 8.1 app solves that without Node.js or a build step: Propshaft: The asset pipeline of a new Rails 8.1 app: it gives every asset a name that ends in a hash of its contents and serves the file unchanged, without compiling or bundling it. gives each
file a name made from its contents, and an import map: A JSON object in a script tag of type importmap that tells the browser which file each JavaScript module name stands for, so modules can import each other by name without a bundler. tells the browser which file each
JavaScript module name means. The runs on this page take apart the Kaksha app’s own head tags: the 8 characters in
application-8b441ae0.css, the 304 response that would keep yesterday’s JavaScript, and the one script tag a strict
policy refuses.
By the end you can say where every asset URL of a new app comes from, add and audit a pinned package, keep cached pages honest after a deploy, and decide when the app needs a bundler after all.
Propshaft names every file after its contents
The layout of a new app asks for its styles with stylesheet_link_tag :app, which links every .css file under
app/assets. Propshaft, the asset pipeline: The part of a Rails app that turns the files under app/assets and the other asset paths into URLs a browser can cache, and serves them; in Rails 8.1 that is Propshaft. of Rails 8.1, turns each file name into
one that ends in a hash of the file. This run renders the head of a page, works the hash out by hand, then adds one
rule to application.css and renders the same page again:
# Where the long name of app/assets/stylesheets/application.css comes from, and what one edit does to it.
# Boots the lesson's copy of the Kaksha app (a default `rails new`, Rails 8.1.4, Propshaft 1.3.2) in development and
# renders a page head through Rack, with no server and no browser.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)
require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"
Rails.application.initialize!
class HeadProbeController < ApplicationController
def show
render inline: <<~ERB
<head><%= stylesheet_link_tag :app, "data-turbo-track": "reload" %></head>
ERB
end
end
Rails.application.routes.draw { get "/" => "head_probe#show" }
def page_head
status, headers, body = Rails.application.call(Rack::MockRequest.env_for("/", "HTTP_HOST" => "kaksha.test"))
html = +""
body.each { |chunk| html << chunk }
[status, headers["link"], html[/<link [^>]+>/]]
end
css = Rails.root.join("app/assets/stylesheets/application.css")
version = Rails.application.config.assets.version
EXTRA_RULE = "\n.fee-due { font-weight: bold; }\n"
original = File.read(css).delete_suffix(EXTRA_RULE) # a run that stopped half-way leaves the folder as it found it
File.write(css, original)
at_exit { File.write(css, original) }
status, link_header, tag = page_head
puts "1. The page head (status #{status})"
puts " tag: #{tag}"
puts " Link header: #{link_header}"
name_digest = tag[/application-(\h{8})\.css/, 1]
sha = Digest::SHA1.hexdigest(original + version.to_s)
puts
puts "2. Where the 8 characters come from"
puts " config.assets.version: #{version.inspect}"
puts " SHA1(file contents + version): #{sha}"
puts " first 8 characters: #{sha[0, 8]}"
puts " the 8 characters in the file name: #{name_digest}"
puts " the same? #{sha[0, 8] == name_digest}"
File.write(css, original + EXTRA_RULE)
Rails.application.assets.load_path.cache_sweeper.execute_if_updated
_, link_after, tag_after = page_head
puts
puts "3. One rule added to application.css, then the same page"
puts " tag: #{tag_after}"
puts " Link header: #{link_after}"
puts " new name? #{tag_after != tag}" Output
1. The page head (status 200) tag: <link rel="stylesheet" href="/assets/application-8b441ae0.css" data-turbo-track="reload" /> Link header: </assets/application-8b441ae0.css>; rel=preload; as=style; nopush 2. Where the 8 characters come from config.assets.version: "1.0" SHA1(file contents + version): 8b441ae02e09b7d7fb3f5d41e77c15714e3f925a first 8 characters: 8b441ae0 the 8 characters in the file name: 8b441ae0 the same? true 3. One rule added to application.css, then the same page tag: <link rel="stylesheet" href="/assets/application-6a683c7b.css" data-turbo-track="reload" /> Link header: </assets/application-6a683c7b.css>; rel=preload; as=style; nopush new name? true
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby digest_probe.rb
Part 2 of the output shows the rule: the first 8 hex characters of the SHA1 of the file’s contents plus the asset
version ("1.0", set in config/initializers/assets.rb) are 8b441ae0, the same 8 characters as in the link. That
part of the name is the asset digest: The hash in an asset's file name, such as 8b441ae0 in application-8b441ae0.css: Propshaft makes it from the file's contents and the asset version, so new contents always get a new name.. One added rule makes it 6a683c7b, so the page
now names a file no browser has seen. The response also carries a Link header with rel=preload, which lets the
browser start the download before it has read the whole page.
Never write /assets/application.css into a template
A hand-written path has no digest, so it bypasses the whole scheme: browsers that cached it keep the old contents.
Always go through a helper (stylesheet_link_tag, image_tag, asset_path), which asks Propshaft for the
current name.
Because a name can never point at different contents, the file can be cached for as long as you like. Step through one page load to see who asks whom, and why the long cache is safe:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The page is requested. The browser asks for the timetable page. Its layout calls stylesheet_link_tag :app, which needs the current file name of every stylesheet under app/assets.
- The helper asks for a name. Propshaft hashes the contents of application.css together with the asset version "1.0" (SHA1) and keeps the first 8 hex characters: 8b441ae0.
- The name goes into the page. The HTML carries a link tag with href /assets/application-8b441ae0.css, and the response adds a Link header with rel=preload, so the browser can start the download early.
- The browser fetches the file. The browser asks for /assets/application-8b441ae0.css. In development Propshaft answers it; in production the web server answers from public/assets, which a deploy wrote once.
- Cached for a year. The file comes back with cache-control max-age of one year. That is safe: the contents behind this name cannot change, because a change would make a different name.
- One edit, a new name. Add one rule to application.css and the hash changes. The next page names application-6a683c7b.css, which the browser has never seen, so it downloads it; the old copy is never asked for again.
Propshaft does not compile anything. Unlike Sprockets, the pipeline of older Rails versions, it never runs Sass,
concatenates files or minifies them; it digests and serves what is in its load path, and rewrites url(…) references
inside CSS to their digested names. A compiler, when you need one, comes from another gem that writes its output into
app/assets/builds, where Propshaft picks it up like any other file.
Key idea
An asset’s URL contains a hash of its contents, so a changed file is a new URL. That is what makes a year-long cache safe: an old name is never reused for new contents.
Checkpoint
Check: Propshaft names each file after its contents
A deploy writes each file once, and a manifest finds it
In development Propshaft hashes files as requests ask for them. In production nothing is hashed at request time: the
deploy runs bin/rails assets:precompile once, which copies every file of the load path into public/assets under its
digested name and writes a manifest that maps each logical name to that file. Here is that command on the same app:
require_relative "session"
# The Kaksha app beside this folder is a default `rails new` (Rails 8.1.4, Propshaft 1.3.2).
Dir.chdir File.expand_path("../kaksha", __dir__)
# public/assets is what the command below writes; start without it and leave without it, so every run is the same.
FileUtils.rm_rf("public/assets")
at_exit { FileUtils.rm_rf("public/assets") }
Session.run(<<~'SH')
# What a deploy runs once (SECRET_KEY_BASE_DUMMY=1 lets the production config load without credentials):
RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
ls public/assets | grep -E '^(application|turbo\.min)-'
# The manifest maps each name a helper asks for to the digested file:
ruby -rjson -e 'm = JSON.parse(File.read("public/assets/.manifest.json")); puts m.size; %w[application.css application.js turbo.min.js].each { |k| puts "#{k} => #{m[k]}" }'
SH Output
# What a deploy runs once (SECRET_KEY_BASE_DUMMY=1 lets the production config load without credentials):
$ RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
Using vips to process variants requires the libvips library. Please install libvips using the instructions on the libvips website.
Writing application-8b441ae0.css
Writing application-bfcdf840.js
Writing controllers/hello_controller-708796bd.js
Writing controllers/index-ee64e1f1.js
Writing controllers/application-3affb389.js
Writing stimulus.min-4b1e420e.js
Writing stimulus-d59b3b7f.js
Writing stimulus.min-2395e199.js.map
Writing stimulus-importmap-autoloader-64cc03e1.js
Writing stimulus-autoloader-9d447422.js
Writing stimulus-loading-1fc53fe7.js
Writing turbo.min-5eb167c2.js.map
Writing turbo-736fb387.js
Writing turbo.min-9fd88cd5.js
Writing actiontext.esm-c376325e.js
Writing actiontext-c9c6c481.js
Writing trix-4bf79781.js
Writing trix-65afdb1d.css
Writing action_cable-5212cfee.js
Writing actioncable.esm-e0ec9819.js
Writing actioncable-ac25813f.js
Writing activestorage.esm-81bb34bc.js
Writing activestorage-f9e46063.js
Writing rails-ujs.esm-e925103b.js
Writing rails-ujs-20eaf715.js
$ ls public/assets | grep -E '^(application|turbo\.min)-'
application-8b441ae0.css
application-bfcdf840.js
turbo.min-5eb167c2.js.map
turbo.min-9fd88cd5.js
# The manifest maps each name a helper asks for to the digested file:
$ ruby -rjson -e 'm = JSON.parse(File.read("public/assets/.manifest.json")); puts m.size; %w[application.css application.js turbo.min.js].each { |k| puts "#{k} => #{m[k]}" }'
25
application.css => {"digested_path" => "application-8b441ae0.css", "integrity" => nil}
application.js => {"digested_path" => "application-bfcdf840.js", "integrity" => nil}
turbo.min.js => {"digested_path" => "turbo.min-9fd88cd5.js", "integrity" => nil}
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby propshaft_digest_session.rb
The first line of output is Active Storage noting that this machine has no libvips image library; it has nothing to do
with assets. Then come 25 Writing lines, one per file of the load path, including Turbo, Stimulus and Action Cable
files that ship inside gems. application-8b441ae0.css has the same digest as in development, because the contents are
the same. The manifest has 25 entries, and stylesheet_link_tag :app in production reads the name from it instead of
hashing anything.
The generated config/environments/production.rb serves public/assets with cache-control: public, max-age=31536000, one year, and the development server adds immutable. A web server or CDN in front of the app can
serve these files without asking Rails at all.
Checkpoint
Check: Precompiled files and the manifest
An import map turns module names into files
JavaScript modules import each other by name: the generated application.js says import "@hotwired/turbo-rails",
not a URL. A bundler would replace those names at build time. An import map leaves them alone and gives the browser a
JSON object, in the page head, that maps each name to a file. config/importmap.rb declares the names, and
bin/importmap json prints the map the layout will send:
require_relative "session"
# The Kaksha app beside this folder is a default `rails new` (Rails 8.1.4, importmap-rails 2.2.3).
Dir.chdir File.expand_path("../kaksha", __dir__)
# The run adds a vendored module and its pin line; put the app back as it was afterwards, so every run starts the same.
importmap = File.read("config/importmap.rb").sub(/^pin "kaksha-fee-format".*\n/, "")
File.write("config/importmap.rb", importmap)
FileUtils.rm_f("vendor/javascript/kaksha-fee-format.js")
at_exit do
File.write("config/importmap.rb", importmap)
FileUtils.rm_f("vendor/javascript/kaksha-fee-format.js")
end
Session.run(<<~'SH')
# The pins of a new app:
cat config/importmap.rb
# The JSON the layout hands to the browser:
bin/importmap json
# The pins that carry a version, which is what the audit can check:
bin/importmap packages
bin/importmap audit
# What bin/importmap pin writes, done by hand for a small module of our own (pin itself asks the JSPM API):
printf 'export function rupees(paise) {\n return (paise / 100).toFixed(2)\n}\n' > vendor/javascript/kaksha-fee-format.js
printf 'pin "kaksha-fee-format" # @1.2.0\n' >> config/importmap.rb
bin/importmap packages
bin/importmap json | grep kaksha-fee-format
SH Output
# The pins of a new app:
$ cat config/importmap.rb
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
# The JSON the layout hands to the browser:
$ bin/importmap json
{
"imports": {
"application": "/assets/application-bfcdf840.js",
"@hotwired/turbo-rails": "/assets/turbo.min-9fd88cd5.js",
"@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js",
"@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc53fe7.js",
"controllers/application": "/assets/controllers/application-3affb389.js",
"controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js",
"controllers": "/assets/controllers/index-ee64e1f1.js"
}
}
# The pins that carry a version, which is what the audit can check:
$ bin/importmap packages
$ bin/importmap audit
No vulnerable packages found
# What bin/importmap pin writes, done by hand for a small module of our own (pin itself asks the JSPM API):
$ printf 'export function rupees(paise) {\n return (paise / 100).toFixed(2)\n}\n' > vendor/javascript/kaksha-fee-format.js
$ printf 'pin "kaksha-fee-format" # @1.2.0\n' >> config/importmap.rb
$ bin/importmap packages
kaksha-fee-format 1.2.0
$ bin/importmap json | grep kaksha-fee-format
"kaksha-fee-format": "/assets/kaksha-fee-format-34a6a2ce.js",
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby importmap_tour_session.rb
The first part of the run is config/importmap.rb with its four pin lines and one pin_all_from, then the JSON:
seven names, each pointing at a digested file under /assets. pin_all_from "app/javascript/controllers" is why three
controller files appear without a line each. The layout’s javascript_importmap_tags writes this JSON into a
<script type="importmap"> tag, a modulepreload link for each file so they download in parallel, and one module
script with import "application". Step through what the browser does with those names:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A bare name, not a URL. The last tag in the head is a module script with one line, import "application". "application" is not a path, so the browser looks it up in the import map instead of guessing a URL.
- The map gives the file. The map answers /assets/application-bfcdf840.js. The modulepreload links in the head have already started that download, so it is often there before it is asked for.
- application.js arrives. The generated application.js has two lines: import "@hotwired/turbo-rails" and import "controllers". Each is another bare name for the same map.
- Turbo comes from a gem. The map says "@hotwired/turbo-rails" is /assets/turbo.min-9fd88cd5.js. That file ships inside the turbo-rails gem, not in vendor/javascript, which is why its pin carries no version.
- Controllers load themselves. "controllers" is index-ee64e1f1.js, which hands every name that pin_all_from pinned under controllers/ to Stimulus, so a new controller file needs no new line in config/importmap.rb.
The map has to come before any script that imports by name, which is why the helper writes it first. Every path in it is digested, so an import map is cached exactly like the stylesheet above: a changed controller is a new path in the map.
Checkpoint
Check: How an import map resolves module names
The audit checks only the pins that carry a version
bin/importmap pin <package> asks the JSPM.org API to resolve a package, downloads the file into
vendor/javascript/, and adds a pin line ending in a comment with the version, such as # @1.2.0. The downloaded
file is served by your own app from then on, so no page depends on a CDN at run time. bin/importmap audit sends the
name and version of every pin that has one to the npm registry’s advisory service and exits with status 1 when any has
a known vulnerability, and the generated config/ci.rb already runs it as one of its steps.
Look back at the second part of the run. On a new app, bin/importmap packages prints nothing and the audit says “No
vulnerable packages found”: none of the four pins has a version comment, because Turbo and Stimulus ship inside the
turbo-rails and stimulus-rails gems. The audit passed because it had nothing to check. Those files change when you
update the gems, and bin/bundler-audit, another step of the same config/ci.rb, checks gems.
The last commands write, by hand, the two things pin writes, for a small module of the app’s own: the file
vendor/javascript/kaksha-fee-format.js and the line pin "kaksha-fee-format" # @1.2.0. Now packages lists
kaksha-fee-format 1.2.0, so the audit would send it, and the JSON maps the new name to
/assets/kaksha-fee-format-34a6a2ce.js.
A green audit is only as wide as its list
Read bin/importmap packages next to the audit: every pin it does not list is one the audit cannot see. Keep the
version comment that pin writes, and remove pins you no longer import, so the list matches what the browser loads.
Checkpoint
Check: What bin/importmap audit can check
A cached page must not keep yesterday’s JavaScript
An action that uses conditional GET answers 304 Not Modified when the browser’s copy is still current, so the page is
not sent again. Its ETag is built from what the action passes to stale? or fresh_when, usually the record. A
timetable whose rows did not change keeps its ETag, even after a deploy that changed application.js, and the
browser keeps a page whose import map names the old file. This run shows both sides, with two controllers that differ in
one line:
# What stale_when_importmap_changes does for a page that answers 304 Not Modified.
# Two controllers render the same timetable page with conditional GET (stale? with the page's own ETag). One inherits
# the generated ApplicationController, which has the line; the other inherits ActionController::Base, which has not.
# Between the requests, app/javascript/application.js changes, as it does in a deploy.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)
require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"
Rails.application.initialize!
PAGE = <<~ERB
<!DOCTYPE html>
<html><head><%= javascript_importmap_tags %></head><body>Batch timetable, week 3</body></html>
ERB
class TimetableController < ApplicationController # has stale_when_importmap_changes
def show
render inline: PAGE if stale?(etag: "timetable-week-3")
end
end
class PlainTimetableController < ActionController::Base # the same action, without the line
def show
render inline: PAGE if stale?(etag: "timetable-week-3")
end
end
Rails.application.routes.draw do
get "/timetable" => "timetable#show"
get "/plain-timetable" => "plain_timetable#show"
end
def get(path, if_none_match = nil)
env = Rack::MockRequest.env_for(path, "HTTP_HOST" => "kaksha.test")
env["HTTP_IF_NONE_MATCH"] = if_none_match if if_none_match
status, headers, body = Rails.application.call(env)
html = +""
body.each { |chunk| html << chunk }
body.close if body.respond_to?(:close)
[status, headers["etag"], html[%r{"application": "(/assets/application-\h{8}\.js)"}, 1]]
end
js = Rails.root.join("app/javascript/application.js")
EXTRA_LINE = "\nconsole.debug(\"timetable week 3\")\n"
original = File.read(js).delete_suffix(EXTRA_LINE) # a run that stopped half-way leaves the folder as it found it
File.write(js, original)
at_exit { File.write(js, original) }
puts "Before the change"
pages = { "/timetable" => "with the line", "/plain-timetable" => "without it" }
etags = {}
pages.each do |path, label|
status, etag, script = get(path)
etags[path] = etag
puts " GET #{path} (#{label}): #{status}, application.js at #{script}"
status, = get(path, etag)
puts " again with If-None-Match: #{status}"
end
File.write(js, original + EXTRA_LINE)
Rails.application.assets.load_path.cache_sweeper.execute_if_updated
Rails.application.importmap.cache_sweeper.execute_if_updated
puts
puts "After one line is added to app/javascript/application.js"
pages.each do |path, label|
status, etag, script = get(path, etags[path])
if status == 304
puts " GET #{path} (#{label}) with the old ETag: 304, so the browser keeps its copy, which loads the old file"
else
puts " GET #{path} (#{label}) with the old ETag: #{status}, a new ETag? #{etag != etags[path]}, application.js at #{script}"
end
end Output
Before the change GET /timetable (with the line): 200, application.js at /assets/application-bfcdf840.js again with If-None-Match: 304 GET /plain-timetable (without it): 200, application.js at /assets/application-bfcdf840.js again with If-None-Match: 304 After one line is added to app/javascript/application.js GET /timetable (with the line) with the old ETag: 200, a new ETag? true, application.js at /assets/application-91cc7155.js GET /plain-timetable (without it) with the old ETag: 304, so the browser keeps its copy, which loads the old file
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby etag_probe.rb
Before the change both answer 200, then 304 to the same ETag. After one line is added to application.js, its file
becomes application-91cc7155.js. The controller that inherits the generated ApplicationController answers 200 with a
new ETag, because stale_when_importmap_changes mixes the digest of the import map into the ETag that stale? and
fresh_when compute for an HTML page. The other answers 304, and its visitors keep running application-bfcdf840.js
until their copy expires.
Tip
The generated ApplicationController already has the line. Keep it there, and make every controller inherit from
ApplicationController, not straight from ActionController::Base, so conditional GET: A request that carries a validator, an ETag or a time, from an earlier response, so the server can answer 304 with no body when the page has not changed since. More in “Filters, errors and HTTP features” stays correct after each deploy.
Checkpoint
Check: Cached pages after a JavaScript change
A nonce lets your scripts run and refuses the rest
A Content Security Policy: A response header that tells the browser which scripts and other resources a page may load or run, for example only files from the app's own origin and tags that carry this response's nonce. is a header the server sends with the page to name
which scripts the browser is allowed to run. With script-src 'self' 'nonce-N', a script runs only if it is a file from the app’s own origin or its
tag carries the attribute nonce="N", where N is a random value made for this one response. A script that an attacker
manages to slip into the page cannot know N, so the browser refuses it. The generated
config/initializers/content_security_policy.rb holds the setup, commented out; this run turns it on with 16 random
bytes (24 characters in base64) as the nonce of every response:
# A nonce-based Content Security Policy on the Kaksha app's page head: which script tags carry the response's nonce.
# The policy is the commented-out example of config/initializers/content_security_policy.rb, made strict (scripts
# only from the app's own origin or with the nonce), with a fresh random nonce for every response.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)
require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.object_src :none
end
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src]
config.content_security_policy_nonce_auto = true
end
Rails.application.initialize!
class NoticeBoardController < ApplicationController
def show
render inline: <<~ERB
<head>
<%= javascript_importmap_tags %>
<%= javascript_tag "window.kakshaTerm = 3" %>
<script>window.addedByHand = true</script>
</head>
ERB
end
end
Rails.application.routes.draw { get "/" => "notice_board#show" }
def fetch
_, headers, body = Rails.application.call(Rack::MockRequest.env_for("/", "HTTP_HOST" => "kaksha.test"))
html = +""
body.each { |chunk| html << chunk }
[headers["content-security-policy"], html]
end
policy, html = fetch
nonce = policy[/'nonce-([^']+)'/, 1]
puts "Content-Security-Policy: #{policy.sub(nonce, "N")}"
puts " (N is this response's nonce: #{nonce.length} random characters)"
puts
puts "Tags in the page head, and whether each carries N:"
html.scan(/<(?:script|link)\b[^>]*>/).each do |tag|
shown = tag.sub(nonce, "N").sub(/ href="[^"]+"/, ' href="…"')
carries = tag.include?(%(nonce="#{nonce}"))
puts " #{carries ? "yes" : "NO "} #{shown}"
end
second_policy, = fetch
puts
puts "A second request gets a different nonce? #{second_policy[/'nonce-([^']+)'/, 1] != nonce}" Output
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-N'; object-src 'none' (N is this response's nonce: 24 random characters) Tags in the page head, and whether each carries N: yes <script type="importmap" data-turbo-track="reload" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <link rel="modulepreload" href="…" nonce="N"> yes <script type="module" nonce="N"> yes <script nonce="N"> NO <script> A second request gets a different nonce? true
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby csp_nonce_probe.rb
Every tag that javascript_importmap_tags writes carries N: the import map, the seven preload links and the module
script. The javascript_tag carries it because content_security_policy_nonce_auto = true adds it to
javascript_tag, javascript_include_tag and stylesheet_link_tag. The <script> typed by hand in the template has
none, so the browser would refuse it; give it nonce="<%= content_security_policy_nonce %>" or move its code into a
module. A second request gets a different nonce, which is what MDN asks of a nonce: a new one for every response.
The commented example uses the session id as the nonce
The generated file suggests request.session.id.to_s, which stays the same for every page of a visit. Use
SecureRandom.base64(16), as this run does, so a nonce seen on one page is useless on the next.
Checkpoint
Check: Nonces in a Content Security Policy
When the default stack is not enough
Import maps load ES modules as they are; nothing compiles them. A file with JSX or TypeScript has to be compiled before
a browser can run it, and a CSS framework built from utility classes needs a build that scans your templates. Each case
has its own gem, and each writes its output into app/assets/builds, where Propshaft digests and serves it as before:
| You need | Add | What it brings |
|---|---|---|
| ES modules, Stimulus, Turbo | nothing | import maps, the default |
| JSX or TypeScript | jsbundling-rails | esbuild, Bun, Rollup or webpack; Node.js or Bun on the machine |
| Tailwind CSS without Node.js | tailwindcss-rails | the standalone Tailwind CLI, through the tailwindcss-ruby gem |
| Sass, PostCSS, Bootstrap or Bulma | cssbundling-rails | a Node.js build with yarn |
Both bundling gems hook their build into assets:precompile, so a deploy runs the bundler first and Propshaft
afterwards, and bin/dev runs their watchers beside the server in development. Their READMEs say when each fits:
cssbundling-rails is for an app that already uses Node.js for its JavaScript, while an app on import maps can avoid
Node.js entirely with the standalone builds of tailwindcss-rails or dartsass-rails.
The rule of thumb
Stay on import maps until a file needs compiling. The first .jsx or .ts file is the moment to add
jsbundling-rails; Tailwind alone is not, because tailwindcss-rails needs no Node.js.
Checkpoint
Check: When to add a bundler or a CSS build
Practise
Every question below comes from the runs on this page: the digest of application.css, the manifest, the seven pins,
the audit that had nothing to check, the 304 that kept old JavaScript, and the nonce on each tag. Answer one and its
explanation opens at once.
Practice that adapts to you
Practise this lesson
10 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does Propshaft do to an asset’s file name, and why does that make long caching safe?
It adds a digest: the first 8 hex characters of a SHA1 of the file’s contents and the asset version, so
application.css is served as application-8b441ae0.css. A change to the contents changes the digest, so the page
names a new file, which no browser or CDN has cached, while the old name keeps its old contents forever. That is why
the files can be served with a cache lifetime of a year: a name is never reused for different contents. In
production the names are computed once by bin/rails assets:precompile, which writes the files to public/assets
and a manifest the helpers read.
Warm-up: on a new app, bin/importmap audit says no vulnerable packages were found. What did it check?
Nothing. The audit sends the name and version of each pin that ends in a version comment, which bin/importmap pin
writes, to the npm registry’s advisory service, and exits with status 1 when one has a known vulnerability. A new
app’s pins for Turbo and Stimulus point at files inside the turbo-rails and stimulus-rails gems and carry no version,
so bin/importmap packages lists nothing and the audit has nothing to send. Those files are checked as gems, by
bin/bundler-audit, which the generated config/ci.rb runs beside the import-map audit. Once you pin a package, it
appears in the list and the audit checks it.
Two questions in the module bank go further than this page: choosing a build for a screen written in JSX, and explaining to a security auditor what a nonce does and does not stop. Work out your own answer first, then open the model answer.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Rails 8.1 app runs on import maps and Propshaft, with a dozen Stimulus controllers. A new fee dashboard is to be written as React components in JSX. What do you change, what happens to the existing controllers, and how does the deploy change?
-
Mid to senior
You turn on a nonce-based Content Security Policy in a Rails 8.1 app. A security auditor asks what it protects against, what it does not, and how you would show that it works before the release. Answer them.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can Propshaft's asset files be cached for a year?
Each file is served under a name that ends in a hash of its contents, so changed contents get a new URL and an old name never points at new contents.
-
What does bin/rails assets:precompile do for production?
It writes every digested file once, together with a manifest through which Rails finds the digested name of each asset.
-
What is an import map, and what does pin_all_from save you?
JSON in the page head that maps each module name, such as @hotwired/turbo-rails, to a digested file, so modules import each other by name with no bundler. pin_all_from adds a whole folder, such as the Stimulus controllers, without a line for each file.
-
Which pins does bin/importmap audit check, and what covers Turbo and Stimulus in a new app?
Only pins that carry a version comment. A new app's Turbo and Stimulus pins come from gems, which bin/bundler-audit checks.
-
After a JavaScript change, why can a page keep answering 304 with yesterday's scripts?
Its ETag does not change when only the JavaScript does. stale_when_importmap_changes, which the generated ApplicationController calls, mixes the import map's digest into the ETag.
-
How does a nonce-based Content Security Policy decide which scripts run?
Tags that carry this response's nonce, such as the import-map tags, run, and a script without it is refused. Each response needs a fresh nonce of its own.
-
When do you add jsbundling-rails, tailwindcss-rails or cssbundling-rails?
jsbundling-rails when code needs compiling, such as JSX or TypeScript; tailwindcss-rails for Tailwind without Node.js; cssbundling-rails for a CSS build that runs on Node.js.
Key takeaways
- Propshaft serves each file under a name that ends in a hash of its contents, so a changed file gets a new URL and a
year-long cache is safe; in production
assets:precompilewrites those files and a manifest once. - An import map is JSON in the page head that maps each module name, such as
@hotwired/turbo-rails, to a digested file, andpin_all_fromadds a folder of controllers without a line each. bin/importmap auditchecks only pins that carry a version comment; a new app’s Turbo and Stimulus pins come from gems, whichbin/bundler-auditchecks instead.- A conditional-GET action keeps answering 304 after a JavaScript change unless
stale_when_importmap_changesmixes the import map’s digest into the ETag, as the generatedApplicationControllerdoes. - A nonce-based Content Security Policy lets the import-map tags run and refuses a script without the nonce, provided the nonce is new for every response.
- Add jsbundling-rails when a file needs compiling (JSX, TypeScript), tailwindcss-rails for Tailwind without Node.js, and cssbundling-rails for a Node.js CSS build.
Terms in this lesson
- asset digest
- The hash in an asset's file name, such as 8b441ae0 in application-8b441ae0.css: Propshaft makes it from the file's contents and the asset version, so new contents always get a new name.
- asset pipeline
- The part of a Rails app that turns the files under app/assets and the other asset paths into URLs a browser can cache, and serves them; in Rails 8.1 that is Propshaft.
- Content Security Policy
- A response header that tells the browser which scripts and other resources a page may load or run, for example only files from the app's own origin and tags that carry this response's nonce.
- import map
- A JSON object in a script tag of type importmap that tells the browser which file each JavaScript module name stands for, so modules can import each other by name without a bundler.
- Propshaft
- The asset pipeline of a new Rails 8.1 app: it gives every asset a name that ends in a hash of its contents and serves the file unchanged, without compiling or bundling it.
References
- Rails Guides: The Asset Pipeline (8.1) (Ruby on Rails)
- Rails Guides: Working with JavaScript in Rails (8.1) (Ruby on Rails)
- Rails Guides: Securing Rails Applications, Content Security Policy (8.1) (Ruby on Rails)
- Propshaft README (Ruby on Rails)
- importmap-rails README (Ruby on Rails)
- jsbundling-rails README (Ruby on Rails)
- cssbundling-rails README (Ruby on Rails)
- tailwindcss-rails README (Ruby on Rails)
- <script type="importmap"> (MDN Web Docs)
- nonce (global attribute) (MDN Web Docs)
Report a problem with this lesson
Kept only in this browser. Your Learn progress