Skip to content

Instantly share code, notes, and snippets.

@nfarina
Created August 8, 2024 17:44
Show Gist options
  • Save nfarina/c405d4c3aaa422fa94e81387684b3680 to your computer and use it in GitHub Desktop.
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.
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