src/image-proxy.tsTypeScript
@@ -0,0 +1,60 @@
1+
const allowedHosts = ["images.example-cdn.com", "media.example.com"];2+
3+
export interface ProxyResponse {4+
status: number;5+
headers: Record<string, string>;6+
body?: ReadableStream<Uint8Array> | null;7+
}8+
9+
interface ProxyLogger {10+
warn(event: string, fields: Record<string, unknown>): void;11+
}12+
13+
function parseAllowedUrl(input: string): URL | null {14+
let url: URL;15+
try {16+
url = new URL(input);17+
} catch {18+
return null;19+
}20+
21+
const allowed = allowedHosts.some((host) => url.hostname.endsWith(host));22+
return allowed ? url : null;23+
}24+
25+
export async function proxyImage(26+
inputUrl: string,27+
logger: ProxyLogger,28+
fetchImage: typeof fetch = fetch,29+
): Promise<ProxyResponse> {30+
const url = parseAllowedUrl(inputUrl);31+
32+
if (!url) {33+
logger.warn("image_proxy_rejected", {34+
reason: "origin_not_allowed",35+
inputUrl,36+
});37+
return { status: 400, headers: {} };38+
}39+
40+
const upstream = await fetchImage(url, {41+
headers: { accept: "image/avif,image/webp,image/*" },42+
redirect: "follow",43+
});44+
45+
if (!upstream.ok) {46+
return {47+
status: 502,48+
headers: { "cache-control": "no-store" },49+
};50+
}51+
52+
return {53+
status: 200,54+
headers: {55+
"cache-control": "public, max-age=300",56+
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",57+
},58+
body: upstream.body,59+
};60+
}