Created
August 8, 2024 17:44
-
-
Save nfarina/c405d4c3aaa422fa94e81387684b3680 to your computer and use it in GitHub Desktop.
Our solution for hosting and transforming images stored in Firebase Storage, using Cloudflare Image Transformations. It's fast (~11ms per request for us) and cheap ($0.50 per million images served through Cloudflare). Modify as needed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
addEventListener("fetch", event => { | |
event.respondWith(handleRequest(event.request)) | |
}) | |
// Our images are immutable, so we can cache them forever. | |
const ONE_YEAR = 31536000; | |
const CACHE_CONTROL = `public, max-age=${ONE_YEAR}, immutable` | |
// One way (the most efficient way) to get the raw images is to fetch them | |
// directly from the underlying Google Cloud Storage bucket. But that requires | |
// marking the whole bucket as public, which is not ideal because at some point | |
// (not at the time of this writing) we may want to restrict access to | |
// non-images in the bucket. | |
// const STORAGE_PUBLIC = "https://storage.googleapis.com/your-google-cloud-project-id.appspot.com/public" | |
// The best way to get the image is to fetch them from Firebase Storage, which | |
// will handle access control through our `storage.rules` file. Firebase Storage | |
// basically wraps Google Cloud Storage, so it adds a bit of latency, but we | |
// cache the raw image fetches below, so it is not really material at scale. | |
const FIREBASE_PUBLIC = "https://firebasestorage.googleapis.com/v0/b/your-firebase-project-id/o/"; | |
/** | |
* @param {Request<unknown, CfProperties<unknown>>} request | |
*/ | |
async function handleRequest(request) { | |
// Parse request URL to get access to query string | |
let url = new URL(request.url); | |
if (!url.pathname || url.pathname === "/") { | |
return new Response('Image path is required.', { status: 400 }); | |
} | |
// Use the file name (which is like a UUID) as an ETag for improved caching. | |
const etag = url.pathname.split("/").pop(); | |
// Check if the request has a matching If-None-Match header | |
const ifNoneMatch = request.headers.get('If-None-Match'); | |
if (ifNoneMatch === etag) { | |
console.log("etag matched"); | |
return new Response(null, { | |
status: 304, | |
headers: { | |
"Cache-Control": CACHE_CONTROL, | |
"Access-Control-Allow-Origin": "*", | |
"ETag": etag, | |
}, | |
}); | |
} | |
// Cloudflare-specific options are in the cf object. | |
const image = {}; | |
// Copy parameters from query string to request options. | |
// These are our own URL parameter names that we came up with | |
// before switching to Cloudflare, so they don't match exactly. | |
const fit = url.searchParams.get("fit") ?? ""; | |
const width = url.searchParams.get("w") ?? ""; | |
const height = url.searchParams.get("h") ?? ""; | |
const quality = url.searchParams.get("q") ?? ""; | |
const format = url.searchParams.get("fm") ?? ""; | |
const dpr = url.searchParams.get("dpr") ?? ""; | |
const fp = url.searchParams.get("fp") ?? ""; | |
const rect = url.searchParams.get("rect") ?? ""; | |
if (fit) image.fit = fit === "fill" ? "cover" : "contain"; | |
if (width) image.width = width; | |
if (height) image.height = height; | |
if (quality) image.quality = quality; | |
if (dpr) image.dpr = dpr; | |
if (fp) { | |
const [x, y] = fp.split(","); | |
console.log(`Setting focal point to ${x},${y}`); | |
image.gravity = {x, y}; | |
} | |
if (rect) { | |
const [x, y, w, h] = rect.split(","); | |
console.log(`Setting rect to ${x},${y},${w},${h}`); | |
image.trim = {left: x, top: y, width: w, height: h}; | |
} | |
// If you are not transforming the image at all, serve the original. | |
if (!fit && !width && !height && !quality && !format && !dpr && !fp && !rect) { | |
console.log("Serving original image.") | |
} | |
else { | |
// Automatic format negotiation. Check the Accept header. | |
const accept = request.headers.get("Accept"); | |
if (/image\/avif/.test(accept)) { | |
image.format = 'avif'; | |
} else if (/image\/webp/.test(accept)) { | |
image.format = 'webp'; | |
} else if (format) { | |
// Cloudflare doesn't always seem to honor the requested format, but | |
// we pass it along anyway. | |
image.format = format; | |
} | |
console.log("Serving image with format " + image.format); | |
} | |
// console.log("Fetching from Google Cloud Functions…"); | |
// const imageURL = STORAGE_PUBLIC + url.pathname; | |
console.log("Fetching from Firebase Storage"); | |
const path = encodeURIComponent("public" + url.pathname); | |
const imageURL = FIREBASE_PUBLIC + path + "?alt=media"; | |
const start = Date.now(); | |
// Fetch the image from Firebase Storage with caching and resizing options. | |
// Cloudflare caches the original image in this fetch, and the resulting resized version, | |
// separately. | |
const resized = await fetch(imageURL, { | |
cf: { | |
// @ts-ignore | |
image, | |
// Add caching options for the fetch to Google Cloud Storage. | |
// This ensures that no matter how many sizes are requested, | |
// we only fetch the original from Cloud Storage once. | |
cacheEverything: true, // Overrides caching headers from Firebase. | |
// Customize our TTL to make sure that, for instance, possibly-temporary | |
// 404s and other errors aren't cached permanently. | |
cacheTtlByStatus: { "200-399": ONE_YEAR, "400-499": 1, "500-599": 0 }, | |
}, | |
}); | |
const end = Date.now(); | |
console.log(`Fetched in ${end - start}ms.`); | |
// Clone the response so we can set the ETag and cache headers. | |
const response = new Response(resized.body, resized); | |
response.headers.set("ETag", etag); | |
response.headers.set("Cache-Control", CACHE_CONTROL); | |
response.headers.set("Access-Control-Allow-Origin", "*"); | |
return response; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment