photos: a browser keeps them, and an avatar loads when it scrolls in
/contacts drew an <img> for every card with a picture, and the photo
route answered each with the whole picture every time: no
Cache-Control, and an ETag it never compared. On a 1,000-card book
with 200 pictures, a phone-speed link (40 ms, 10 Mbit/s) took 10.6 s
and 12 MB to load /contacts, and as long again on every visit.
The route now sends Cache-Control: private, no-cache with an ETag of
the picture's own bytes, and Roda's caching plugin answers a held
picture with a 304. The avatar is loading=lazy, so only the pictures
near the screen are asked for. The group photo route shares the
helper.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZeaKvJp7q4WEwoCPqVkZb
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
index 24f3dd0..d4f5ab9 100644
--- a/lib/pro_tacts/admin/avatar.rb
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -15,7 +15,10 @@ module ProTacts
# bytes in hand, so the page carries a URL where it would otherwise
# carry megabytes of base64 (see the route in Web). Alt is empty:
# the contact's name is adjacent text in every slot, so the image
- # is decoration, not information.
+ # is decoration, not information. Loaded lazily, because /contacts
+ # draws an avatar for every card in the book and a browser would
+ # otherwise fetch every picture before the page counts as loaded,
+ # most of them far below anything on screen.
class Avatar < Phlex::HTML
# @rbs @contact: Contact | ListedContact
# @rbs @size: String?
@@ -28,7 +31,7 @@ module ProTacts
def view_template
if @contact.photo?
- img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", **attributes)
+ img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", loading: "lazy", **attributes)
else
span(class: "avatar", **attributes) { Format.initials(@contact) }
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d362581..a471f4a 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -4,6 +4,7 @@ require "pathname"
require "pro_tacts"
require "sentry-ruby"
+require "rack/deflater"
require "rack/rewindable_input"
require "roda"
@@ -48,6 +49,15 @@ module ProTacts
end
end
+ # The pages and the files they load, gzipped for a browser that
+ # asks: /contacts is half a megabyte of markup that compresses
+ # thirtyfold, and a phone on the tailnet pays for every byte. Only
+ # those types, so a DAV response — XML or a card — goes out as the
+ # bytes the clients were confirmed against (docs/carddav.md), and a
+ # picture, compressed already, is not squeezed again. Outermost,
+ # so the exchange log below records a body as the app wrote it.
+ use Rack::Deflater, include: %w[text/html text/css text/javascript]
+
# RewindableInput lets the exchange log below read the request body
# after the application has.
use Rack::RewindableInput::Middleware
@@ -67,6 +77,7 @@ module ProTacts
plugin :all_verbs
plugin :dav_verbs
plugin :public, root: PUBLIC_ROOT.to_s
+ plugin :caching
plugin :hash_branches
# A DAV 404 warns through RefusalAlerts; an admin one reports nothing.
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 91c61e0..a742bd6 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -1,3 +1,5 @@
+require "digest"
+
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/contact_dialog"
require "pro_tacts/admin/contacts_edit"
@@ -120,17 +122,14 @@ module ProTacts
# bytes under their own content type, served from a route
# rather than inlined as base64 so a page of avatars is a
# page of cacheable image requests instead of ten 330 KB
- # payloads stitched into the HTML. The contact's etag, so a
- # picture changes exactly when its card does.
+ # payloads stitched into the HTML (#serve_photo).
r.get "photo" do
contact = store.contact(id)
# The views never point at a photo the card lacks, so a
# contact with none is the 404 case above.
if contact && (photo = contact.photo)
- response["Content-Type"] = photo.mime_type
- response["ETag"] = contact.etag
- photo.bytes
+ serve_photo(photo)
end
end
@@ -144,6 +143,22 @@ module ProTacts
private
+ # A picture, a contact's or a group's, answered so a browser keeps
+ # it: an ETag of its own bytes and `no-cache`, which stores the
+ # picture but asks before each reuse, so a page of avatars costs a
+ # 304 apiece rather than every picture again on every visit, and a
+ # replaced picture shows the moment it is. The bytes' digest rather
+ # than the card's etag, so an edit to a phone number keeps the
+ # picture a browser holds. Roda's caching plugin answers the 304,
+ # halting before the bytes are sent.
+ #: (Contact::Photo photo) -> String
+ def serve_photo(photo)
+ response["Content-Type"] = photo.mime_type
+ response.cache_control(private: true, no_cache: true)
+ request.etag(Digest::SHA256.hexdigest(photo.bytes))
+ photo.bytes
+ end
+
# The dashboard GET and a refused create render the same page.
#: (?notice: String?) -> String
def dashboard(notice: nil)
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index b57b119..8c16d07 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -1,5 +1,3 @@
-require "digest"
-
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/groups_edit"
require "pro_tacts/admin/groups_index"
@@ -60,11 +58,7 @@ module ProTacts
# The page never points at a photo the group lacks, so one
# with none is the 404 case.
- if photo
- response["Content-Type"] = photo.mime_type
- response["ETag"] = %("#{Digest::SHA256.hexdigest(photo.bytes)}")
- photo.bytes
- end
+ serve_photo(photo) if photo
end
r.post do
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 52466f8..3650e2c 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -83,12 +83,20 @@ class Roda
# Dispatches to the hash_branch for the next segment, if one is
# defined, and ends the request with its answer.
def hash_branches: (?String | Symbol namespace) -> void
+
+ # The caching plugin's: sets the ETag, quoting the value, and halts
+ # with a 304 when the request's If-None-Match already names it.
+ def etag: (String value) -> void
end
class RodaResponse
def []=: (String header, String value) -> void
def status=: (Integer status) -> void
def write: (String body) -> void
+
+ # The caching plugin's: a Cache-Control header from its
+ # directives, each key spelled with underscores for dashes.
+ def cache_control: (**bool directives) -> void
end
class RodaError < StandardError
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index edc0a06..2f249bc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1271,7 +1271,7 @@ class AdminContactsPagesTest < Minitest::Test
get "/contacts/pic"
assert_equal 200, last_response.status
- assert_includes last_response.body, '<img class="avatar" src="/contacts/pic/photo" alt=""'
+ assert_includes last_response.body, '<img class="avatar" src="/contacts/pic/photo" alt="" loading="lazy"'
assert_includes last_response.body, 'data-size="xl"'
refute_includes last_response.body, '<span class="avatar"'
end
@@ -1291,16 +1291,41 @@ class AdminContactsPagesTest < Minitest::Test
end
# The route the avatars point at: the decoded bytes under the type
- # their own magic bytes name, with the contact's etag — so a picture
- # changes exactly when its card does.
+ # their own magic bytes name, with an etag of those bytes.
def test_the_photo_route_serves_the_decoded_bytes
with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
get "/contacts/pic/photo"
+ bytes = store.contact("pic").photo.bytes
assert_equal 200, last_response.status
assert_equal "image/jpeg", last_response["Content-Type"]
- assert_equal store.contact("pic").photo.bytes, last_response.body
- assert_equal store.contact("pic").etag, last_response["ETag"]
+ assert_equal bytes, last_response.body
+ assert_equal %("#{Digest::SHA256.hexdigest(bytes)}"), last_response["ETag"]
+ assert_equal "private, no-cache", last_response["Cache-Control"]
+ end
+ end
+
+ # A browser holding the picture asks with its etag and is told to
+ # keep it, so a page of avatars it has seen sends no picture again
+ # (Web#serve_photo). An edit that leaves the picture alone keeps
+ # its etag; a new picture is a new one, and is sent.
+ def test_the_photo_route_answers_a_held_picture_with_a_304
+ with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
+ get "/contacts/pic/photo"
+ etag = last_response["ETag"]
+
+ get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+ assert_equal 304, last_response.status
+ assert_empty last_response.body
+
+ store.put("pic", ProTacts::VCard.new(PhotoCard.photo("pic", bytes: 256, extra: ["NICKNAME:Countess"])))
+ get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+ assert_equal 304, last_response.status
+
+ store.put("pic", ProTacts::VCard.new(PhotoCard.photo("pic", bytes: 512)))
+ get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+ assert_equal 200, last_response.status
+ refute_equal etag, last_response["ETag"]
end
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index c3dda56..b854160 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -381,6 +381,10 @@ class AdminGroupsPagesTest < Minitest::Test
assert_equal 200, last_response.status
assert_equal "image/jpeg", last_response["Content-Type"]
assert_equal JPEG, last_response.body.b
+
+ # Held by a browser the way a contact's is (Web#serve_photo).
+ get "/groups/#{id}/photo", {}, "HTTP_IF_NONE_MATCH" => last_response["ETag"]
+ assert_equal 304, last_response.status
end
end