1818 */
1919
2020import { badRequestResponse } from "./http-error-responses.js" ;
21+ import { stripBasePath } from "../utils/base-path.js" ;
2122
2223/** The pathname that triggers image optimization (matches Next.js). */
2324export const IMAGE_OPTIMIZATION_PATH = "/_next/image" ;
@@ -76,6 +77,10 @@ export type ImageConfig = {
7677 * via the image shim instead.
7778 */
7879 dangerouslyAllowLocalIP ?: boolean ;
80+ /** Maximum source response body size. Defaults to 50 MB. */
81+ maximumResponseBody ?: number ;
82+ /** Minimum optimized image cache lifetime in seconds. Defaults to 4 hours. */
83+ minimumCacheTTL ?: number ;
7984 /** Content-Disposition header value. Default: "inline". */
8085 contentDispositionType ?: "inline" | "attachment" ;
8186 /** Content-Security-Policy header value. Default: "script-src 'none'; frame-src 'none'; sandbox;" */
@@ -201,7 +206,7 @@ export function negotiateImageFormat(acceptHeader: string | null): string {
201206 * Standard Cache-Control header for optimized images.
202207 * Optimized images are immutable because the URL encodes the transform params.
203208 */
204- export const IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable " ;
209+ export const IMAGE_CACHE_CONTROL = "public, max-age=14400, must-revalidate " ;
205210
206211/**
207212 * Content-Security-Policy for image optimization responses.
@@ -222,7 +227,11 @@ const SAFE_IMAGE_CONTENT_TYPES = new Set([
222227 "image/gif" ,
223228 "image/webp" ,
224229 "image/avif" ,
230+ "image/heic" ,
231+ "image/jp2" ,
232+ "image/jxl" ,
225233 "image/x-icon" ,
234+ "image/x-icns" ,
226235 "image/vnd.microsoft.icon" ,
227236 "image/bmp" ,
228237 "image/tiff" ,
@@ -244,6 +253,96 @@ export function isSafeImageContentType(
244253 return false ;
245254}
246255
256+ async function readImageSource (
257+ response : Response ,
258+ maximumResponseBody : number ,
259+ ) : Promise <
260+ { response : Response ; contentType : string | null ; tooLarge : false } | { tooLarge : true } | null
261+ > {
262+ if ( ! response . body ) return null ;
263+ const reader = response . body . getReader ( ) ;
264+ const chunks : Uint8Array [ ] = [ ] ;
265+ let totalSize = 0 ;
266+ while ( true ) {
267+ const { done, value } = await reader . read ( ) ;
268+ if ( done ) break ;
269+ totalSize += value . byteLength ;
270+ if ( totalSize > maximumResponseBody ) {
271+ await reader . cancel ( ) ;
272+ return { tooLarge : true } ;
273+ }
274+ chunks . push ( value ) ;
275+ }
276+ const bytes = new Uint8Array ( totalSize ) ;
277+ let offset = 0 ;
278+ for ( const chunk of chunks ) {
279+ bytes . set ( chunk , offset ) ;
280+ offset += chunk . byteLength ;
281+ }
282+ const startsWith = ( ...signature : number [ ] ) =>
283+ bytes . length >= signature . length && signature . every ( ( byte , index ) => bytes [ index ] === byte ) ;
284+ const textPrefix = new TextDecoder ( )
285+ . decode ( bytes . subarray ( 0 , 256 ) )
286+ . replace ( / ^ \uFEFF / , "" )
287+ . trimStart ( )
288+ . toLowerCase ( ) ;
289+ let contentType : string | null = null ;
290+ if ( startsWith ( 0xff , 0xd8 , 0xff ) ) contentType = "image/jpeg" ;
291+ else if ( startsWith ( 0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ) ) contentType = "image/png" ;
292+ else if ( startsWith ( 0x47 , 0x49 , 0x46 , 0x38 ) ) contentType = "image/gif" ;
293+ if (
294+ startsWith ( 0x52 , 0x49 , 0x46 , 0x46 ) &&
295+ bytes . length >= 12 &&
296+ bytes [ 8 ] === 0x57 &&
297+ bytes [ 9 ] === 0x45 &&
298+ bytes [ 10 ] === 0x42 &&
299+ bytes [ 11 ] === 0x50
300+ )
301+ contentType = "image/webp" ;
302+ else if ( bytes . length >= 12 && startsWith ( 0x00 , 0x00 , 0x00 , 0x0c , 0x4a , 0x58 , 0x4c , 0x20 ) )
303+ contentType = "image/jxl" ;
304+ else if ( startsWith ( 0xff , 0x0a ) ) contentType = "image/jxl" ;
305+ else if ( bytes . length >= 12 && startsWith ( 0x00 , 0x00 , 0x00 , 0x0c , 0x6a , 0x50 , 0x20 , 0x20 ) )
306+ contentType = "image/jp2" ;
307+ else if (
308+ bytes . length >= 12 &&
309+ bytes [ 4 ] === 0x66 &&
310+ bytes [ 5 ] === 0x74 &&
311+ bytes [ 6 ] === 0x79 &&
312+ bytes [ 7 ] === 0x70
313+ ) {
314+ const brand = String . fromCharCode ( bytes [ 8 ] , bytes [ 9 ] , bytes [ 10 ] , bytes [ 11 ] ) ;
315+ if ( brand === "avif" || brand === "avis" ) contentType = "image/avif" ;
316+ else if ( brand === "heic" ) contentType = "image/heic" ;
317+ } else if ( startsWith ( 0x00 , 0x00 , 0x01 , 0x00 ) ) contentType = "image/x-icon" ;
318+ else if ( startsWith ( 0x69 , 0x63 , 0x6e , 0x73 ) ) contentType = "image/x-icns" ;
319+ else if ( startsWith ( 0x42 , 0x4d ) ) contentType = "image/bmp" ;
320+ else if ( startsWith ( 0x49 , 0x49 , 0x2a , 0x00 ) || startsWith ( 0x4d , 0x4d , 0x00 , 0x2a ) )
321+ contentType = "image/tiff" ;
322+ else if ( textPrefix . startsWith ( "<?xml" ) || textPrefix . startsWith ( "<svg" ) )
323+ contentType = "image/svg+xml" ;
324+
325+ return {
326+ response : new Response ( bytes , {
327+ status : response . status ,
328+ statusText : response . statusText ,
329+ headers : response . headers ,
330+ } ) ,
331+ contentType,
332+ tooLarge : false ,
333+ } ;
334+ }
335+
336+ function isFreshImageRequest ( request : Request , etag : string ) : boolean {
337+ if ( request . headers . get ( "Cache-Control" ) ?. toLowerCase ( ) . includes ( "no-cache" ) ) return false ;
338+ const ifNoneMatch = request . headers . get ( "If-None-Match" ) ;
339+ if ( ! ifNoneMatch ) return false ;
340+ const normalize = ( value : string ) => value . trim ( ) . replace ( / ^ W \/ / , "" ) ;
341+ return ifNoneMatch
342+ . split ( "," )
343+ . some ( ( value ) => value . trim ( ) === "*" || normalize ( value ) === normalize ( etag ) ) ;
344+ }
345+
247346/**
248347 * Apply security headers to an image optimization response.
249348 * These headers are set on every response from the image endpoint,
@@ -262,14 +361,37 @@ function setImageSecurityHeaders(headers: Headers, config?: ImageConfig): void {
262361 ) ;
263362}
264363
265- function createPassthroughImageResponse ( source : Response , config ?: ImageConfig ) : Response {
266- const headers = new Headers ( source . headers ) ;
267- headers . set ( "Cache-Control" , IMAGE_CACHE_CONTROL ) ;
364+ function createPassthroughImageResponse (
365+ source : Response ,
366+ config ?: ImageConfig ,
367+ request ?: Request ,
368+ detectedContentType ?: string ,
369+ ) : Response {
370+ const headers = new Headers ( ) ;
371+ const contentType = detectedContentType ?? source . headers . get ( "Content-Type" ) ;
372+ const etag = source . headers . get ( "ETag" ) ;
373+ if ( contentType ) headers . set ( "Content-Type" , contentType ) ;
374+ if ( etag ) headers . set ( "ETag" , etag ) ;
375+ headers . set ( "Cache-Control" , imageCacheControl ( source , config ) ) ;
268376 headers . set ( "Vary" , "Accept" ) ;
269377 setImageSecurityHeaders ( headers , config ) ;
378+ if ( etag && request && isFreshImageRequest ( request , etag ) ) {
379+ headers . delete ( "Content-Type" ) ;
380+ return new Response ( null , { status : 304 , headers } ) ;
381+ }
270382 return new Response ( source . body , { status : 200 , headers } ) ;
271383}
272384
385+ function imageCacheControl ( source : Response , config ?: ImageConfig ) : string {
386+ const upstreamCacheControl = source . headers . get ( "Cache-Control" ) ;
387+ if ( upstreamCacheControl ?. toLowerCase ( ) . includes ( "immutable" ) ) return upstreamCacheControl ;
388+ const upstreamMaxAge = source . headers
389+ . get ( "Cache-Control" )
390+ ?. match ( / (?: ^ | , ) \s * (?: s - m a x a g e | m a x - a g e ) = ( \d + ) / i) ?. [ 1 ] ;
391+ const maxAge = Math . max ( config ?. minimumCacheTTL ?? 14_400 , Number ( upstreamMaxAge ?? 0 ) ) ;
392+ return `public, max-age=${ maxAge } , must-revalidate` ;
393+ }
394+
273395/**
274396 * Handlers for image optimization I/O operations.
275397 * Workers provide these callbacks to adapt their specific bindings.
@@ -284,6 +406,29 @@ export type ImageHandlers = {
284406 ) => Promise < Response > ;
285407} ;
286408
409+ /**
410+ * Build the request used to resolve a local image source through the app's
411+ * normal request pipeline. This uses a credential-free synthetic GET request,
412+ * matching Next.js and preventing external rewrites from forwarding caller credentials.
413+ */
414+ export function createInternalImageRequest (
415+ imageUrl : string ,
416+ request : Request ,
417+ basePath = "" ,
418+ ) : Request | null {
419+ const sourceUrl = new URL ( imageUrl , request . url ) ;
420+ let sourcePathname : string ;
421+ try {
422+ sourcePathname = decodeURIComponent ( sourceUrl . pathname ) ;
423+ } catch {
424+ return null ;
425+ }
426+ if ( isImageOptimizationPath ( stripBasePath ( sourcePathname , basePath ) ) ) return null ;
427+ return new Request ( sourceUrl , {
428+ method : "GET" ,
429+ } ) ;
430+ }
431+
287432/**
288433 * Handle image optimization requests.
289434 *
@@ -307,9 +452,19 @@ export async function handleImageOptimization(
307452 const { imageUrl, width, quality } = params ;
308453
309454 // Fetch source image
310- const source = await handlers . fetchAsset ( imageUrl , request ) ;
311- if ( ! source . ok || ! source . body ) {
312- return new Response ( "Image not found" , { status : 404 } ) ;
455+ const sourceResult = await readImageSource (
456+ await handlers . fetchAsset ( imageUrl , request ) ,
457+ imageConfig ?. maximumResponseBody ?? 50_000_000 ,
458+ ) ;
459+ if ( sourceResult ?. tooLarge ) {
460+ return new Response ( "The requested resource is too large." , { status : 413 } ) ;
461+ }
462+ if ( ! sourceResult ) {
463+ return new Response ( "The requested resource isn't a valid image." , { status : 400 } ) ;
464+ }
465+ const { response : source , contentType : sourceContentType } = sourceResult ;
466+ if ( ! sourceContentType ) {
467+ return new Response ( "The requested resource isn't a valid image." , { status : 400 } ) ;
313468 }
314469
315470 // Negotiate output format from Accept header
@@ -318,29 +473,34 @@ export async function handleImageOptimization(
318473 // Block unsafe Content-Types (e.g., SVG which can contain embedded scripts).
319474 // Check the source Content-Type before any processing. SVG is only allowed
320475 // when dangerouslyAllowSVG is explicitly enabled in next.config.js.
321- const sourceContentType = source . headers . get ( "Content-Type" ) ;
322476 if ( ! isSafeImageContentType ( sourceContentType , imageConfig ?. dangerouslyAllowSVG ) ) {
323477 return new Response ( "The requested resource is not an allowed image type" , { status : 400 } ) ;
324478 }
325479
326480 // SVG passthrough: SVG is a vector format, so transformation (resize, format
327481 // conversion) provides no benefit. Serve as-is with security headers.
328482 // This matches Next.js behavior where SVG is a "bypass type".
329- const sourceMediaType = sourceContentType ?. split ( ";" ) [ 0 ] . trim ( ) . toLowerCase ( ) ;
330- if ( sourceMediaType === "image/svg+xml" ) {
331- return createPassthroughImageResponse ( source , imageConfig ) ;
483+ if ( sourceContentType === "image/svg+xml" ) {
484+ return createPassthroughImageResponse ( source , imageConfig , request , sourceContentType ) ;
332485 }
333486
334487 // Transform if handler provided, otherwise serve original
335488 if ( handlers . transformImage ) {
336489 try {
337- const transformed = await handlers . transformImage ( source . body , {
490+ const transformed = await handlers . transformImage ( source . body ! , {
338491 width,
339492 format,
340493 quality,
341494 } ) ;
342- const headers = new Headers ( transformed . headers ) ;
343- headers . set ( "Cache-Control" , IMAGE_CACHE_CONTROL ) ;
495+ if ( ! transformed . ok || ! transformed . body ) {
496+ throw new Error ( `Image transform returned ${ transformed . status } ` ) ;
497+ }
498+ const headers = new Headers ( ) ;
499+ const transformedContentType = transformed . headers . get ( "Content-Type" ) ;
500+ const transformedEtag = transformed . headers . get ( "ETag" ) ;
501+ if ( transformedContentType ) headers . set ( "Content-Type" , transformedContentType ) ;
502+ if ( transformedEtag ) headers . set ( "ETag" , transformedEtag ) ;
503+ headers . set ( "Cache-Control" , imageCacheControl ( source , imageConfig ) ) ;
344504 headers . set ( "Vary" , "Accept" ) ;
345505 setImageSecurityHeaders ( headers , imageConfig ) ;
346506
@@ -350,6 +510,11 @@ export async function handleImageOptimization(
350510 headers . set ( "Content-Type" , format ) ;
351511 }
352512
513+ if ( transformedEtag && isFreshImageRequest ( request , transformedEtag ) ) {
514+ headers . delete ( "Content-Type" ) ;
515+ return new Response ( null , { status : 304 , headers } ) ;
516+ }
517+
353518 return new Response ( transformed . body , { status : 200 , headers } ) ;
354519 } catch ( e ) {
355520 console . error ( "[vinext] Image optimization error:" , e ) ;
@@ -358,20 +523,31 @@ export async function handleImageOptimization(
358523
359524 // Fallback: serve original image with cache headers
360525 try {
361- return createPassthroughImageResponse ( source , imageConfig ) ;
526+ return createPassthroughImageResponse ( source , imageConfig , request , sourceContentType ) ;
362527 } catch ( e ) {
363528 console . error ( "[vinext] Image fallback error, refetching source image:" , e ) ;
364- const refetchedSource = await handlers . fetchAsset ( imageUrl , request ) ;
365- if ( ! refetchedSource . ok || ! refetchedSource . body ) {
529+ const refetchedResult = await readImageSource (
530+ await handlers . fetchAsset ( imageUrl , request ) ,
531+ imageConfig ?. maximumResponseBody ?? 50_000_000 ,
532+ ) ;
533+ if ( refetchedResult ?. tooLarge ) {
534+ return new Response ( "The requested resource is too large." , { status : 413 } ) ;
535+ }
536+ if ( ! refetchedResult || ! refetchedResult . response . ok ) {
366537 return new Response ( "Image not found" , { status : 404 } ) ;
367538 }
368539
369- const refetchedContentType = refetchedSource . headers . get ( "Content-Type" ) ;
540+ const { response : refetchedSource , contentType : refetchedContentType } = refetchedResult ;
370541 if ( ! isSafeImageContentType ( refetchedContentType , imageConfig ?. dangerouslyAllowSVG ) ) {
371542 return new Response ( "The requested resource is not an allowed image type" , { status : 400 } ) ;
372543 }
373544
374- return createPassthroughImageResponse ( refetchedSource , imageConfig ) ;
545+ return createPassthroughImageResponse (
546+ refetchedSource ,
547+ imageConfig ,
548+ request ,
549+ refetchedContentType ?? undefined ,
550+ ) ;
375551 }
376552}
377553
0 commit comments