Feat: Move from Pages to a Worker and serve popular images as static files

Static assets don't count toward the free plan's 100,000 requests a day, so a
daily GitHub Action promotes the most viewed images (by Cloudflare analytics)
from R2 into public/ before deploying, and reports to ntfy. Zone rules fold
the site and ?i= into the path, since static files ignore query strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 17:08:34 +00:00
co-authored by Claude Opus 5.5
parent fb3635dd47
commit 32f742b73f
30 changed files with 471 additions and 41 deletions
+50
View File
@@ -0,0 +1,50 @@
// Sends the deploy's outcome to ntfy: what got promoted, how close the account is to the free plan's limits, and what
// to do when something needs attention. Runs last in .github/workflows/deploy.yml, even when an earlier step failed.
import { readFile } from "node:fs/promises";
const env = process.env;
const r = JSON.parse(await readFile("promoted/report.json", "utf8").catch(() => "{}"));
const fmt = n => Math.round(n).toLocaleString("en-US");
const gb = b => `${(b / 1e9).toFixed(2)} GB`;
const pct = (a, b) => `${Math.round((100 * a) / b)}%`;
const lines = [], actions = [];
if (env.STATUS !== "success") {
actions.push(`${env.FAILED_STEP || "A step"} failed, so the site still runs the previous deploy. Open the run for the error.`);
}
if (r.ok) {
lines.push(`Static images: ${fmt(r.promoted)} / ${fmt(r.maxFiles)} slots, ${gb(r.bytes)} / ${gb(r.maxBytes)}`);
lines.push(`+${fmt(r.added)} promoted, −${fmt(r.removed)} dropped (min ${r.minViews} views in ${r.days} days)`);
if (r.views?.total) lines.push(`They got ${pct(r.promotedViews, r.views.total)} of ${fmt(r.views.total)} image views in ${r.days} days`);
// web/1/orange+cat → orange cat, free/2/orange+cat → orange cat i=2 [free]
const label = id => id.replace(/^(\w+)\/(\d+)\/(.*)$/, (_, f, i, q) => `${q.replaceAll("+", " ")}${i > 1 ? ` i=${i}` : ""}${f !== "web" ? ` [${f}]` : ""}`);
if (r.top?.length) lines.push(`Top: ${r.top.map(t => `${label(t.id)} (${fmt(t.views)})`).join(", ")}`);
if (r.notFitted) actions.push(`All ${fmt(r.maxFiles)} static slots are full and ~${fmt(r.notFitted)} more images qualified. Workers Paid ($5/mo) allows 100,000 files.`);
if (r.skipped.budget) actions.push(`Size budget full: ${fmt(r.skipped.budget)} images left out. Raise MAX_BYTES in deploy.yml (runner disk ~14 GB, Actions cache 10 GB).`);
if (r.skipped.failed) actions.push(`${fmt(r.skipped.failed)} downloads from R2 failed, e.g. ${r.firstError}. They're retried next run.`);
if (r.analyticsError) actions.push(`Couldn't read Cloudflare analytics (${r.analyticsError}), so the last run's images were kept. Check the API token's Analytics permission.`);
}
if (r.workers) {
const { total, byScript } = r.workers;
const top = Object.entries(byScript).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([s, n]) => `${s} ${fmt(n)}`).join(", ");
lines.push(`Worker requests yesterday: ${fmt(total)} / 100,000 (${pct(total, 1e5)})${top && `: ${top}`}`);
if (total >= 8e4) actions.push(`Worker requests hit ${pct(total, 1e5)} of the free 100,000/day; past it, images that aren't static files break until 00:00 UTC. Lower MIN_VIEWS to promote more, or move to Workers Paid ($5/mo).`);
}
if (r.workersError) lines.push(`Couldn't read Worker usage: ${r.workersError}`);
const failed = env.STATUS !== "success";
const body = [...lines, ...actions.map(a => `⚠ ${a}`)].join("\n") || "No report was written.";
console.log(body);
if (!env.NTFY_URL) process.exit(0);
const res = await fetch(env.NTFY_URL.startsWith("http") ? env.NTFY_URL : `https://${env.NTFY_URL}`, {
method: "POST",
body,
headers: {
Title: failed ? "direct-img deploy failed" : actions.length ? "direct-img deploy needs attention" : `direct-img deployed (${env.TRIGGER || "manual"})`,
Priority: failed ? "5" : actions.length ? "4" : "2",
Tags: failed ? "rotating_light,x" : actions.length ? "warning" : "white_check_mark",
...(env.RUN_URL && { Click: env.RUN_URL }),
...(env.NTFY_TOKEN && { Authorization: `Bearer ${env.NTFY_TOKEN}` }),
},
});
console.log(`ntfy: HTTP ${res.status}`);
+173
View File
@@ -0,0 +1,173 @@
// Copies the most viewed images from R2 into public/, so the next deploy serves them as static files: free, unlimited
// and without running the worker. Views come from Cloudflare's analytics, which count static files too, so a promoted
// image that stops being viewed drops out. Run before every deploy (.github/workflows/deploy.yml); writes
// promoted/report.json for scripts/notify.js. promoted/ is kept between runs (Actions cache), so only new images download.
import { mkdir, readFile, writeFile, link, rm, readdir } from "node:fs/promises";
import { dirname } from "node:path";
import { MAX_INDEX, siteOf, folder, normalizeQuery, cacheKey, r2Key } from "../src/_utils/keys.js";
import { parseFreeSource } from "../src/_utils/free.js";
import { parseAiSource } from "../src/_utils/ai.js";
const env = process.env;
const ZONE = env.ZONE || "direct-img.link", BUCKET = "direct-img-store";
const HOSTS = [ZONE, `free.${ZONE}`, `ai.${ZONE}`];
// The free plan allows 20,000 static files per deploy; the rest is room for index.html, assets/ etc.
const MAX_FILES = +(env.MAX_FILES || 19900);
// Views an image needs over the last DAYS to be promoted
const MIN_VIEWS = +(env.MIN_VIEWS || 20), DAYS = +(env.DAYS || 7);
// Total size of promoted images, which the runner restores from cache (or downloads) and uploads on every deploy
const MAX_BYTES = +(env.MAX_BYTES || 4e9);
// Formats browsers detect from the bytes, since every promoted image is declared image/jpeg (public/_headers)
const TYPES = ["image/jpeg", "image/jpg", "image/pjpeg", "image/png", "image/gif", "image/webp"];
// Queries whose file name the rules or the asset lookup would change, so a promoted file would never match
const BAD_NAME = /[+.%\/\\?#\x00-\x1f\x7f]/;
const CACHE = "promoted", OUT = "public";
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cloudflare's API, retrying rate limits (1,200 requests per 5 minutes) and server errors
async function api(path, init = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" } });
if ((res.status !== 429 && res.status < 500) || attempt === 5) return res;
await sleep((+res.headers.get("retry-after") || 2 ** attempt) * 1000);
}
}
async function gql(query) {
const r = await (await api("/graphql", { method: "POST", body: JSON.stringify({ query: `{ viewer { ${query} } }` }) })).json();
if (r.errors?.length) throw new Error(r.errors[0].message);
return r.data.viewer;
}
// The image a request was for: a folded path (/web/2/orange+cat) or a bare query (/orange+cat), which is ?i=1 since
// the rules redirect every ?i= to its folded path. null for anything else (the homepage, assets, another site's folder).
async function parse(host, path) {
const site = siteOf(host), m = path.match(/^\/(?:(web|free|ai)\/(\d+)\/)?([^/.]+)$/);
if (!m || (m[1] && m[1] !== folder(site))) return null;
// A promoted image's +s come back as %2B from the asset redirect, while the rules lowercase a query's own %2b
const i = +(m[2] || 1), query = normalizeQuery(m[1] ? m[3].replaceAll("%2B", "+") : m[3]);
if (!query || query.length > 200 || i < 1 || i > MAX_INDEX[site]) return null;
const name = query.replaceAll(" ", "+");
if (BAD_NAME.test(query) || Buffer.byteLength(name) > 200) return null;
const src = site === "free" ? parseFreeSource(new URLSearchParams()) : site === "ai" ? parseAiSource(new URLSearchParams()) : null;
return { id: `${folder(site)}/${i}/${name}`, r2: await r2Key(site, cacheKey(site, src, i, query)) };
}
// Views per image over the last DAYS: one query per host and day, since a query returns at most 10,000 rows. Only 200s
// count, so a promoted image isn't counted again for the redirect that encodes its + as %2B.
async function views(zoneTag) {
const totals = new Map(), now = Date.now();
let total = 0;
for (const host of HOSTS) for (let d = 0; d < DAYS; d++) {
const range = `datetime_geq: "${new Date(now - (d + 1) * 864e5).toISOString()}", datetime_lt: "${new Date(now - d * 864e5).toISOString()}"`;
const { zones } = await gql(`zones(filter: {zoneTag: "${zoneTag}"}) { httpRequestsAdaptiveGroups(limit: 10000, orderBy: [count_DESC], filter: {${range}, clientRequestHTTPHost: "${host}", edgeResponseStatus: 200}) { count dimensions { clientRequestPath } } }`);
for (const { count, dimensions } of zones[0].httpRequestsAdaptiveGroups) {
const img = await parse(host, dimensions.clientRequestPath);
if (!img) continue;
total += count;
totals.set(img.id, { ...img, views: (totals.get(img.id)?.views || 0) + count });
}
}
return { list: [...totals.values()], total };
}
// Requests per Worker over a UTC day, which is what the free plan's 100,000 a day counts. Every Pages project's Functions
// count too, as one entry. (A limit over 100 silently returns nothing.)
async function workerRequests(day) {
const filter = `limit: 100, filter: {datetime_geq: "${day}", datetime_lt: "${new Date(Date.parse(day) + 864e5).toISOString()}"}) { sum { requests } dimensions { scriptName } }`;
const [a] = (await gql(`accounts(filter: {accountTag: "${env.CLOUDFLARE_ACCOUNT_ID}"}) { w: workersInvocationsAdaptive(${filter} p: pagesFunctionsInvocationsAdaptiveGroups(${filter} }`)).accounts;
const byScript = {};
for (const { sum, dimensions } of a.w) byScript[dimensions.scriptName] = (byScript[dimensions.scriptName] || 0) + sum.requests;
const pages = a.p.reduce((t, r) => t + r.sum.requests, 0);
if (pages) byScript["Pages Functions"] = pages;
return { total: Object.values(byScript).reduce((t, r) => t + r, 0), byScript };
}
// The image from the last run's cache, or from R2. Skips a failed search's empty marker and formats browsers can't detect.
async function fetchImage(img, prev) {
const file = `${CACHE}/files/${img.id}`;
if (prev[img.id] && await readFile(file).then(() => true, () => false)) return { ...prev[img.id], cached: true };
const res = await api(`/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${BUCKET}/objects/${img.r2}`);
if (res.status === 404) return { skip: "missing" };
if (!res.ok) return { skip: "failed", error: `${img.id}: HTTP ${res.status}` };
const type = res.headers.get("content-type")?.split(";")[0].trim().toLowerCase(), buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return { skip: "missing" };
if (!TYPES.includes(type)) return { skip: "type" };
if (buf.length > 25 * 2 ** 20) return { skip: "size" };
await mkdir(dirname(file), { recursive: true });
await writeFile(file, buf);
return { bytes: buf.length, type };
}
const report = { ok: false, minViews: MIN_VIEWS, days: DAYS, maxFiles: MAX_FILES, maxBytes: MAX_BYTES, skipped: {} };
const prev = JSON.parse(await readFile(`${CACHE}/manifest.json`, "utf8").catch(() => "{}"));
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10) + "T00:00:00Z";
let ranked;
try {
const zone = (await (await api(`/zones?name=${ZONE}`)).json()).result[0].id;
const { list, total } = await views(zone);
ranked = list.filter(c => c.views >= MIN_VIEWS).sort((a, b) => b.views - a.views);
report.views = { total };
} catch (err) {
// Keeps the last run's images rather than dropping them all because analytics had a bad moment
report.analyticsError = err.message;
ranked = Object.entries(prev).map(([id, p]) => ({ id, ...p })).sort((a, b) => b.views - a.views);
}
report.qualified = ranked.length;
try {
report.workers = await workerRequests(yesterday);
} catch (err) {
report.workersError = err.message;
}
// Most viewed first, until the slots or the size budget run out. 4 at a time keeps downloads under the API's rate limit.
const chosen = {};
let bytes = 0, n = 0;
for (let k = 0; k < ranked.length; k += 4) {
if (n >= MAX_FILES) {
report.notFitted = ranked.length - k;
break;
}
const batch = ranked.slice(k, k + 4), started = Date.now();
const got = await Promise.all(batch.map(img => fetchImage(img, prev).catch(err => ({ skip: "failed", error: `${img.id}: ${err.message}` }))));
got.forEach((g, j) => {
const img = batch[j];
if (g.skip) {
report.skipped[g.skip] = (report.skipped[g.skip] || 0) + 1;
report.firstError ||= g.error;
} else if (n >= MAX_FILES) report.notFitted = (report.notFitted || 0) + 1;
else if (bytes + g.bytes > MAX_BYTES) report.skipped.budget = (report.skipped.budget || 0) + 1;
else {
chosen[img.id] = { views: img.views, bytes: g.bytes, type: g.type, r2: img.r2 };
bytes += g.bytes;
n++;
}
});
if (got.some(g => !g.cached)) await sleep(1200 - (Date.now() - started));
}
// public/<folder>/ holds only this run's images, hard-linked from the cache so they aren't stored twice
for (const f of ["web", "free", "ai"]) await rm(`${OUT}/${f}`, { recursive: true, force: true });
for (const id of Object.keys(chosen)) {
await mkdir(dirname(`${OUT}/${id}`), { recursive: true });
await link(`${CACHE}/files/${id}`, `${OUT}/${id}`);
}
// Images no longer promoted leave the cache too, so it doesn't grow past the size budget
for (const e of await readdir(`${CACHE}/files`, { recursive: true, withFileTypes: true }).catch(() => [])) {
const id = `${e.parentPath.slice(`${CACHE}/files/`.length)}/${e.name}`;
if (e.isFile() && !chosen[id]) await rm(`${CACHE}/files/${id}`);
}
await mkdir(CACHE, { recursive: true });
await writeFile(`${CACHE}/manifest.json`, JSON.stringify(chosen));
const ids = Object.keys(chosen);
Object.assign(report, {
ok: true, promoted: ids.length, bytes,
added: ids.filter(id => !prev[id]).length, removed: Object.keys(prev).filter(id => !chosen[id]).length,
promotedViews: ids.reduce((a, id) => a + chosen[id].views, 0),
top: ids.slice(0, 5).map(id => ({ id, views: chosen[id].views })),
});
await writeFile(`${CACHE}/report.json`, JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
+38
View File
@@ -0,0 +1,38 @@
// Sets the zone's rules that fold the site and ?i= into the path, so promoted images can be static files (which ignore
// query strings): a URL Rewrite turns /orange+cat into /web/1/orange+cat, and a Redirect sends /orange+cat?i=2 to
// /web/2/orange+cat, which also makes ?i= show up in analytics. Replaces only the rules it made earlier.
// node scripts/rules.js direct-img.link free.direct-img.link ai.direct-img.link
// With no hostnames it removes them. Needs CLOUDFLARE_API_TOKEN with Zone > Transform Rules and Single Redirect edit.
import { siteOf, folder } from "../src/_utils/keys.js";
const env = process.env, hosts = process.argv.slice(2), ZONE = env.ZONE || "direct-img.link", TAG = "direct-img fold:";
const api = (path, init) => fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" } }).then(r => r.json());
// A bare query: not the homepage, assets/, robots.txt or an already folded path (which a redirect leads to). A literal +
// (%2B) is left to the worker, since a promoted file's + stands for a space.
const FLAT = 'http.request.uri.path ne "/" and not http.request.uri.path contains "." and not http.request.uri.path wildcard "/*/*" and not lower(http.request.uri.path) contains "%2b"';
const bySite = Object.groupBy(hosts, h => folder(siteOf(h)));
const rules = {
http_request_transform: Object.entries(bySite).map(([f, hs]) => ({
description: `${TAG} ${f} bare query → /${f}/1/`,
expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query eq "" and ${FLAT}`,
action: "rewrite",
action_parameters: { uri: { path: { expression: `concat("/${f}/1", lower(http.request.uri.path))` } } },
})),
http_request_dynamic_redirect: Object.entries(bySite).map(([f, hs]) => ({
description: `${TAG} ${f} ?i= → /${f}/<i>/`,
expression: `http.host in {${hs.map(h => JSON.stringify(h)).join(" ")}} and http.request.uri.query strict wildcard "i=*" and not http.request.uri.query contains "&" and ${FLAT}`,
action: "redirect",
action_parameters: { from_value: { status_code: 302, preserve_query_string: false, target_url: { expression: `concat("https://", http.host, "/${f}/", http.request.uri.args["i"][0], lower(http.request.uri.path))` } } },
})),
};
const zone = (await api(`/zones?name=${ZONE}`)).result[0].id;
for (const [phase, mine] of Object.entries(rules)) {
const others = ((await api(`/zones/${zone}/rulesets/phases/${phase}/entrypoint`)).result?.rules || [])
.filter(r => !r.description?.startsWith(TAG))
.map(({ id, version, last_updated, ref, ...r }) => r);
const res = await api(`/zones/${zone}/rulesets/phases/${phase}/entrypoint`, { method: "PUT", body: JSON.stringify({ rules: [...others, ...mine] }) });
console.log(phase, res.success ? `${mine.length} set, ${others.length} others kept` : JSON.stringify(res.errors));
if (!res.success) process.exitCode = 1;
}