Skip to content

Commit 46e1bfe

Browse files
committed
Cache clear
1 parent af5fda4 commit 46e1bfe

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

bin/purge_changed_cache

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "digest"
5+
require "json"
6+
require "net/http"
7+
require "optparse"
8+
require "pathname"
9+
require "uri"
10+
11+
options = {
12+
batch_size: 100,
13+
base_url: "https://cb341.dev"
14+
}
15+
16+
OptionParser.new do |parser|
17+
parser.banner = "Usage: bin/purge_changed_cache OLD_BUILD_DIR NEW_BUILD_DIR [options]"
18+
19+
parser.on("-u", "--base-url URL", "Public site URL, default: https://cb341.dev") do |value|
20+
options[:base_url] = value.delete_suffix("/")
21+
end
22+
23+
parser.on("-n", "--dry-run", "Print URLs without calling Cloudflare") do
24+
options[:dry_run] = true
25+
end
26+
end.parse!
27+
28+
old_dir, new_dir = ARGV.map { |path| Pathname(path).expand_path }
29+
30+
abort "OLD_BUILD_DIR is required" unless old_dir
31+
abort "NEW_BUILD_DIR is required" unless new_dir
32+
abort "#{old_dir} does not exist" unless old_dir.directory?
33+
abort "#{new_dir} does not exist" unless new_dir.directory?
34+
35+
def file_hashes(root)
36+
root.find.each_with_object({}) do |path, hashes|
37+
next unless path.file?
38+
39+
relative_path = path.relative_path_from(root).to_s
40+
hashes[relative_path] = Digest::SHA256.file(path).hexdigest
41+
end
42+
end
43+
44+
def public_urls(base_url, path)
45+
if path == "index.html"
46+
["#{base_url}/", "#{base_url}/index.html"]
47+
elsif path.end_with?("/index.html")
48+
directory_url = "#{base_url}/#{path.delete_suffix('/index.html')}/"
49+
explicit_url = "#{base_url}/#{path}"
50+
[directory_url, explicit_url]
51+
else
52+
["#{base_url}/#{path}"]
53+
end
54+
end
55+
56+
old_hashes = file_hashes(old_dir)
57+
new_hashes = file_hashes(new_dir)
58+
59+
changed_paths = (old_hashes.keys | new_hashes.keys).select do |path|
60+
old_hashes[path] != new_hashes[path]
61+
end
62+
63+
urls = changed_paths.flat_map { |path| public_urls(options[:base_url], path) }.uniq.sort
64+
65+
if urls.empty?
66+
puts "No changed cacheable URLs found."
67+
exit
68+
end
69+
70+
puts "Purging #{urls.length} changed URL(s):"
71+
urls.each { |url| puts " #{url}" }
72+
73+
exit if options[:dry_run]
74+
75+
zone_id = ENV.fetch("CLOUDFLARE_ZONE_ID") do
76+
abort "CLOUDFLARE_ZONE_ID is required"
77+
end
78+
79+
api_token = ENV.fetch("CLOUDFLARE_API_TOKEN") do
80+
abort "CLOUDFLARE_API_TOKEN is required"
81+
end
82+
83+
endpoint = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/purge_cache")
84+
85+
urls.each_slice(options[:batch_size]) do |batch|
86+
response = Net::HTTP.start(endpoint.host, endpoint.port, use_ssl: true) do |http|
87+
request = Net::HTTP::Post.new(endpoint)
88+
request["Authorization"] = "Bearer #{api_token}"
89+
request["Content-Type"] = "application/json"
90+
request.body = JSON.generate(files: batch)
91+
http.request(request)
92+
end
93+
94+
body = JSON.parse(response.body)
95+
next if response.is_a?(Net::HTTPSuccess) && body["success"]
96+
97+
warn JSON.pretty_generate(body)
98+
abort "Cloudflare purge request failed with HTTP #{response.code}"
99+
end

bin/warm_cache

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "net/http"
5+
require "optparse"
6+
require "rexml/document"
7+
require "thread"
8+
require "uri"
9+
10+
options = {
11+
concurrency: 4,
12+
sitemap: "https://cb341.dev/sitemap.xml",
13+
timeout: 15
14+
}
15+
16+
OptionParser.new do |parser|
17+
parser.banner = "Usage: bin/warm_cache [options]"
18+
19+
parser.on("-s", "--sitemap URL", "Sitemap URL to read") do |value|
20+
options[:sitemap] = value
21+
end
22+
23+
parser.on("-j", "--concurrency COUNT", Integer, "Parallel requests, default: 4") do |value|
24+
options[:concurrency] = [value, 1].max
25+
end
26+
27+
parser.on("-t", "--timeout SECONDS", Integer, "Per-request timeout, default: 15") do |value|
28+
options[:timeout] = [value, 1].max
29+
end
30+
end.parse!
31+
32+
def fetch(uri, timeout, limit = 5)
33+
raise "too many redirects for #{uri}" if limit.zero?
34+
35+
response = Net::HTTP.start(
36+
uri.host,
37+
uri.port,
38+
use_ssl: uri.scheme == "https",
39+
open_timeout: timeout,
40+
read_timeout: timeout
41+
) do |http|
42+
request = Net::HTTP::Get.new(uri)
43+
request["User-Agent"] = "cb341-cache-warmer/1.0"
44+
http.request(request)
45+
end
46+
47+
case response
48+
when Net::HTTPRedirection
49+
fetch(URI(response.fetch("location")), timeout, limit - 1)
50+
else
51+
response
52+
end
53+
end
54+
55+
sitemap_uri = URI(options[:sitemap])
56+
sitemap = fetch(sitemap_uri, options[:timeout])
57+
abort "Could not fetch sitemap: HTTP #{sitemap.code}" unless sitemap.is_a?(Net::HTTPSuccess)
58+
59+
document = REXML::Document.new(sitemap.body)
60+
urls = REXML::XPath.each(document, "//*[local-name()='url']/*[local-name()='loc']").map do |element|
61+
URI(element.text.strip)
62+
end
63+
64+
abort "No URLs found in #{options[:sitemap]}" if urls.empty?
65+
66+
queue = Queue.new
67+
urls.each { |url| queue << url }
68+
69+
mutex = Mutex.new
70+
failures = []
71+
completed = 0
72+
73+
workers = Array.new(options[:concurrency]) do
74+
Thread.new do
75+
loop do
76+
url = queue.pop(true)
77+
response = fetch(url, options[:timeout])
78+
cache = response["cf-cache-status"] || "-"
79+
80+
mutex.synchronize do
81+
completed += 1
82+
puts format("[%<done>d/%<total>d] %<code>s %<cache>s %<url>s",
83+
done: completed,
84+
total: urls.length,
85+
code: response.code,
86+
cache: cache,
87+
url: url)
88+
failures << [url, response.code] unless response.is_a?(Net::HTTPSuccess)
89+
end
90+
rescue ThreadError
91+
break
92+
rescue StandardError => error
93+
mutex.synchronize do
94+
completed += 1
95+
puts "[#{completed}/#{urls.length}] ERROR #{url}: #{error.message}"
96+
failures << [url, error.message]
97+
end
98+
end
99+
end
100+
end
101+
102+
workers.each(&:join)
103+
104+
if failures.any?
105+
warn "\n#{failures.length} request(s) failed."
106+
exit 1
107+
end

0 commit comments

Comments
 (0)