Skip to content

Commit c7cab97

Browse files
authored
serve css/js assets under digest urls, cache-control (#929)
Changes the caching strategy for assets. All assets {PATH} (e.g. css/main.css) are now served under two URLs: 1. /{PATH} (Cache-control header allows caching for 1 day) 2. /_/{DIGEST}/{PATH} (Cache-control header allows caching for a very long time and declares the file immutable) Digests are looked up via assets.ml (produced by ocaml-crunch with mode plain). They are a hash of the file content. In the templates, there is now a parameter digest_url : string -> string which renders the path containing the digest (only for assets from assets.ml, as we do not expect files from media.ml to change). Note: Cache-control: immutable tells the browser that checking whether the file was modified is unnecessary. Resolves #839 except for the playground. Note that, since inter.css has a relative import for the font files, all the font files are served under inter.css's digest. So, if anyone ever needs to update the font files, they have to modify inter.css to change its digest.
1 parent 544f432 commit c7cab97

File tree

11 files changed

+138
-74
lines changed

11 files changed

+138
-74
lines changed

src/ocamlorg_frontend/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
(library
22
(name ocamlorg_frontend)
3-
(libraries dream ood timedesc))
3+
(libraries dream ood timedesc ocamlorg_static))
44

55
(include_subdirs unqualified)
66

src/ocamlorg_frontend/layouts/layout.eml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@ inner =
3434
<link rel="icon" type="image/x-icon" href="/favicon.ico">
3535
<link rel="manifest" href="/manifest.json">
3636
<% (match styles with | Some styles -> styles |> List.iter (fun style -> %>
37-
<link rel="stylesheet" href="<%s style %>">
37+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url style %>">
3838
<% ) | None -> %>
39-
<link rel="stylesheet" href="/css/main.css">
39+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "css/main.css" %>">
4040
<% ); %>
41-
<link rel="stylesheet" href="/vendors/font-files/inter.css">
42-
<script defer src="/vendors/alpine.min.js"></script>
43-
<script defer src="/vendors/htmx.min.js"></script>
41+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "vendors/font-files/inter.css" %>">
42+
<script defer src="<%s Ocamlorg_static.asset_url "vendors/alpine.min.js" %>"></script>
43+
<script defer src="<%s Ocamlorg_static.asset_url "vendors/htmx.min.js" %>"></script>
4444
<% if use_swiper then ( %>
45-
<link rel="stylesheet" href="/vendors/swiper-bundle.min.css">
45+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "vendors/swiper-bundle.min.css" %>">
4646
<link rel="alternate" type="application/rss+xml" title="OCaml RSS Feed" href="/feed.xml">
47-
<script src="/vendors/swiper-bundle.min.js"></script>
47+
<script src="<%s Ocamlorg_static.asset_url "vendors/swiper-bundle.min.js" %>"></script>
4848
<% ); %>
4949
<title><%s title %></title>
5050
</head>

src/ocamlorg_frontend/pages/package_documentation.eml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Package_layout.render
5050
~package
5151
~path
5252
~canonical:(Url.package_doc package.name ~version:package.version ?page:path_page)
53-
~styles:["/css/main.css"; "/css/doc.css"] @@
53+
~styles:["css/main.css"; "css/doc.css"] @@
5454
<div x-data="{ open: false, sidebar: window.innerWidth > 1024 && true, showOnMobile: false}" @resize.window="sidebar = window.innerWidth > 1024" x-on:close-sidebar="sidebar=window.innerWidth > 1024 && true">
5555
<button :title='(sidebar? "close" : "open")+" sidebar"' class="flex items-center bg-primary-600 p-3 z-30 rounded-full text-white shadow-md bottom-20 fixed lg:hidden right-10"
5656
x-on:click="sidebar = ! sidebar">

src/ocamlorg_frontend/pages/playground.eml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ let render () =
1717
<link rel="canonical" href="https://ocaml.org<%s Url.playground %>">
1818
<link rel="icon" type="image/x-icon" href="/favicon.ico">
1919
<link rel="manifest" href="/manifest.json">
20-
<link rel="stylesheet" href="/css/main.css">
21-
<link rel="stylesheet" href="/css/codemirror.css">
22-
<link rel="stylesheet" href="/vendors/font-files/inter.css">
23-
<script defer src="/vendors/alpine.min.js"></script>
20+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "css/main.css" %>">
21+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "css/codemirror.css" %>">
22+
<link rel="stylesheet" href="<%s Ocamlorg_static.asset_url "vendors/font-files/inter.css" %>">
23+
<script defer src="<%s Ocamlorg_static.asset_url "vendors/alpine.min.js" %>"></script>
2424
<title>OCaml Playground</title>
2525
</head>
2626

src/ocamlorg_static/dune

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
(library
2+
(name ocamlorg_static)
3+
(libraries fmt dream))
4+
5+
(rule
6+
(targets asset.ml)
7+
(deps
8+
%{workspace_root}/asset/css/main.css
9+
(source_tree %{workspace_root}/asset))
10+
(action
11+
(with-stdout-to
12+
%{null}
13+
(run %{bin:ocaml-crunch} -m plain %{workspace_root}/asset -o asset.ml))))
14+
15+
(rule
16+
(targets media.ml)
17+
(deps
18+
(source_tree %{workspace_root}/data/media))
19+
(action
20+
(with-stdout-to
21+
%{null}
22+
(run
23+
%{bin:ocaml-crunch}
24+
-m
25+
plain
26+
%{workspace_root}/data/media
27+
-o
28+
media.ml))))

src/ocamlorg_static/file.ml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
let to_url_path ?digest filepath =
2+
match digest with
3+
| None -> filepath
4+
| Some digest -> Fmt.str "/_/%s/%s" digest filepath
5+
6+
type t = { filepath : string; digest : string option }
7+
8+
let of_url_path path =
9+
let xs = String.split_on_char '/' path in
10+
match xs with
11+
| "_" :: x :: xs -> Some { digest = Some x; filepath = String.concat "/" xs }
12+
| "_" :: _ | [] -> None
13+
| _ -> Some { digest = None; filepath = path }

src/ocamlorg_static/file.mli

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
(* We serve static files (e.g. assets) under two relative URLs:
2+
3+
/{PATH}
4+
5+
and
6+
7+
/_/{DIGEST}/{PATH}
8+
9+
For the latter path, we allow browsers to cache the file received for a very
10+
long time, treating it like an immutable resource with a unique URL based on
11+
a file content digest.
12+
13+
This module implements references to static files (with or without digest)
14+
and conversions between (1) a file path + optional digest, and (2) the
15+
corresponding relative URL under which the static file is served via HTTP. *)
16+
17+
(* renders a relative URL from the optional [digest] and [filepath] *)
18+
val to_url_path : ?digest:string -> string -> string
19+
20+
type t = { filepath : string; digest : string option }
21+
22+
(* tries to convert a relative URL to a static file reference by extracting the
23+
digest (if applicable) and filepath *)
24+
val of_url_path : string -> t option
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module Asset = Asset
2+
module Media = Media
3+
4+
let of_url_path = File.of_url_path
5+
6+
(* given the path of a file from `assets.ml`: 1. looks up the file's digest in
7+
and 2. returns the corresponding digest URL for use in templates *)
8+
let asset_url filepath =
9+
let digest =
10+
Option.map (fun d -> Dream.to_base64url d) (Asset.hash filepath)
11+
in
12+
if digest = None then
13+
raise
14+
(Invalid_argument
15+
(Fmt.str
16+
"ERROR: '%s' is rendered via asset_url, but it is not an asset!"
17+
filepath));
18+
File.to_url_path ?digest filepath

src/ocamlorg_web/lib/dune

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,3 @@
1111
ood
1212
timedesc
1313
mirage-kv-mem))
14-
15-
(rule
16-
(targets asset.ml asset.mli)
17-
(deps
18-
%{workspace_root}/asset/css/main.css
19-
(source_tree %{workspace_root}/asset))
20-
(action
21-
(with-stdout-to
22-
%{null}
23-
(run %{bin:ocaml-crunch} -m lwt %{workspace_root}/asset -o asset.ml))))
24-
25-
(rule
26-
(targets media.ml media.mli)
27-
(deps
28-
(source_tree %{workspace_root}/data/media))
29-
(action
30-
(with-stdout-to
31-
%{null}
32-
(run %{bin:ocaml-crunch} -m lwt %{workspace_root}/data/media -o media.ml))))

src/ocamlorg_web/lib/router.ml

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
11
module Url = Ocamlorg_frontend.Url
22

33
let asset_loader =
4-
let open Lwt.Syntax in
5-
let store = Asset.connect () in
64
Static.loader
7-
~read:(fun _root path ->
8-
let* store = store in
9-
Asset.get store (Mirage_kv.Key.v path))
10-
~last_modified:(fun _root path ->
11-
let* store = store in
12-
Asset.last_modified store (Mirage_kv.Key.v path))
13-
~not_cached:[ "css/main.css"; "/css/main.css"; "robots.txt"; "/robots.txt" ]
5+
~read:(fun _root path -> Ocamlorg_static.Asset.read path)
6+
~digest:(fun _root path ->
7+
Option.map Dream.to_base64url (Ocamlorg_static.Asset.hash path))
8+
~not_cached:[ "robots.txt"; "/robots.txt" ]
149

1510
let media_loader =
16-
let open Lwt.Syntax in
17-
let store = Media.connect () in
1811
Static.loader
19-
~read:(fun _root path ->
20-
let* store = store in
21-
Media.get store (Mirage_kv.Key.v path))
22-
~last_modified:(fun _root path ->
23-
let* store = store in
24-
Media.last_modified store (Mirage_kv.Key.v path))
12+
~read:(fun _root path -> Ocamlorg_static.Media.read path)
13+
~digest:(fun _root path ->
14+
Option.map Dream.to_base64url @@ Ocamlorg_static.Media.hash path)
2515

2616
let page_routes =
2717
Dream.scope ""

src/ocamlorg_web/lib/static.ml

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,39 @@ let not_modified ~last_modified request =
3636
| None -> false
3737
| Some date -> String.equal date last_modified
3838

39-
let max_age = 60 * 60 * 24 (* one day *)
39+
let loader ~read ~digest ?(not_cached = []) local_root path request =
40+
let not_cached = List.mem path not_cached in
41+
let maybe_static_file = Ocamlorg_static.of_url_path path in
42+
match maybe_static_file with
43+
| None -> Dream.not_found request
44+
| Some static_file -> (
45+
let filepath = static_file.filepath in
46+
let result = read local_root filepath in
47+
match result with
48+
| None -> Handler.not_found request
49+
| Some asset when not_cached ->
50+
Dream.respond
51+
~headers:
52+
([ ("Cache-Control", "no-store, max-age=0") ]
53+
@ Dream.mime_lookup path)
54+
asset
55+
| Some asset ->
56+
let digest = digest local_root filepath in
57+
if
58+
static_file.digest <> None
59+
&& not (Option.equal ( = ) digest static_file.digest)
60+
then
61+
Dream.log "asset %s exists but digest does not match: %s != %s"
62+
filepath
63+
(Option.value ~default:"" static_file.digest)
64+
(Dream.to_base64url (Option.value ~default:"" digest));
4065

41-
let loader ~read ~last_modified ?(not_cached = []) local_root path request =
42-
let open Lwt.Syntax in
43-
let* last_modified = last_modified local_root path in
44-
match last_modified with
45-
| Error _ -> Handler.not_found request
46-
| Ok last_modified -> (
47-
let last_modified = Last_modified.ptime_to_http_date last_modified in
48-
if not_modified ~last_modified request then
49-
Dream.respond ~status:`Not_Modified ""
50-
else
51-
let* result = read local_root path in
52-
match result with
53-
| Error _ -> Handler.not_found request
54-
| Ok asset when List.mem path not_cached ->
55-
Dream.respond ~headers:(Dream.mime_lookup path) asset
56-
| Ok asset ->
57-
Dream.respond
58-
~headers:
59-
([
60-
("Cache-Control", Fmt.str "max-age=%d" max_age);
61-
("Last-Modified", last_modified);
62-
]
63-
@ Dream.mime_lookup path)
64-
asset)
66+
let cache_control =
67+
match static_file.digest with
68+
| None -> "max-age=86400" (* one day *)
69+
| Some _ -> "max-age=31536000, immutable"
70+
in
71+
Dream.respond
72+
~headers:
73+
([ ("Cache-Control", cache_control) ] @ Dream.mime_lookup path)
74+
asset)

0 commit comments

Comments
 (0)