@@ -28,11 +28,7 @@ import { emptyReadableStream, toReadableStream } from "utils/stream.js";
2828import type { OpenNextHandlerOptions } from "types/overrides.js" ;
2929import { createGenericHandler } from "../core/createGenericHandler.js" ;
3030import { resolveImageLoader } from "../core/resolve.js" ;
31- import {
32- FatalError ,
33- IgnorableError ,
34- type RecoverableError ,
35- } from "../utils/error.js" ;
31+ import { IgnorableError } from "../utils/error.js" ;
3632import { debug , error } from "./logger.js" ;
3733import { optimizeImage } from "./plugins/image-optimization/image-optimization.js" ;
3834import { setNodeEnv } from "./util.js" ;
@@ -87,9 +83,10 @@ export async function defaultHandler(
8783 // We return a 400 here if imageParams returns an errorMessage
8884 // https://github.com/vercel/next.js/blob/512d8283054407ab92b2583ecce3b253c3be7b85/packages/next/src/server/next-server.ts#L937-L941
8985 if ( "errorMessage" in imageParams ) {
90- // Use IgnorableError for client-side validation issues (logLevel 0) to prevent monitoring alerts
91- const clientError = new IgnorableError ( imageParams . errorMessage , 400 ) ;
92- error ( "Error during validation of image params" , clientError ) ;
86+ error (
87+ "Error during validation of image params" ,
88+ imageParams . errorMessage ,
89+ ) ;
9390 return buildFailureResponse (
9491 imageParams . errorMessage ,
9592 options ?. streamCreator ,
@@ -118,17 +115,19 @@ export async function defaultHandler(
118115 ) ;
119116 return buildSuccessResponse ( result , options ?. streamCreator , etag ) ;
120117 } catch ( e : any ) {
121- // Determine if this is a client error (4xx) and convert it to appropriate error type
122- const classifiedError = classifyError ( e ) ;
118+ // All image-related errors should be treated as client errors
119+ // Extract status code from error or default to 400 Bad Request
120+ const statusCode = e . statusCode || 400 ;
121+ const errorMessage = e . message || "Failed to process image request" ;
123122
124- // Log with the appropriate level based on the error type
125- error ( "Image optimization error" , classifiedError ) ;
123+ // Create an IgnorableError for proper monitoring classification
124+ const clientError = new IgnorableError ( errorMessage , statusCode ) ;
125+ error ( "Failed to optimize image" , clientError ) ;
126126
127- // Pass through the error message from Next.js
128127 return buildFailureResponse (
129- classifiedError . message || "Internal server error" ,
128+ errorMessage ,
130129 options ?. streamCreator ,
131- classifiedError . statusCode ,
130+ statusCode , // Use the appropriate status code (preserving original when available)
132131 ) ;
133132 }
134133}
@@ -137,52 +136,6 @@ export async function defaultHandler(
137136// Helper functions //
138137//////////////////////
139138
140- /**
141- * Classifies an error as either a client error (IgnorableError) or server error (FatalError)
142- * to ensure proper logging behavior without triggering false monitoring alerts.
143- *
144- * The primary goal is to preserve the original error information while ensuring
145- * client errors don't trigger monitoring alerts.
146- */
147- function classifyError ( e : any ) : IgnorableError | RecoverableError | FatalError {
148- // If it's already an OpenNext error, return it directly
149- if ( e && typeof e === "object" && "__openNextInternal" in e ) {
150- return e ;
151- }
152-
153- // Preserve the original message
154- const message = e ?. message || "Internal server error" ;
155-
156- // Preserve the original status code if available, otherwise use a default
157- let statusCode = 500 ;
158- if (
159- e &&
160- typeof e === "object" &&
161- "statusCode" in e &&
162- typeof e . statusCode === "number"
163- ) {
164- statusCode = e . statusCode ;
165- }
166-
167- // Simple check for client errors - anything with a 4xx status code
168- // or common error codes that indicate client issues
169- const isClientError =
170- ( statusCode >= 400 && statusCode < 500 ) ||
171- ( e &&
172- typeof e === "object" &&
173- ( e . code === "ENOTFOUND" ||
174- e . code === "ECONNREFUSED" ||
175- e . code === "ETIMEDOUT" ) ) ;
176-
177- // Wrap client errors as IgnorableError to prevent monitoring alerts
178- if ( isClientError ) {
179- return new IgnorableError ( message , statusCode ) ;
180- }
181-
182- // Server errors are marked as FatalError to ensure proper monitoring
183- return new FatalError ( message , statusCode ) ;
184- }
185-
186139function validateImageParams (
187140 headers : OutgoingHttpHeaders ,
188141 query ?: InternalEvent [ "query" ] ,
@@ -295,124 +248,140 @@ async function downloadHandler(
295248 // directly.
296249 debug ( "downloadHandler url" , url ) ;
297250
298- // Reads the output from the Writable and writes to the response
299- const pipeRes = ( w : Writable , res : ServerResponse ) => {
300- w . pipe ( res )
251+ /**
252+ * Helper function to handle image errors consistently with appropriate response
253+ * @param e The error object
254+ * @param res The server response object
255+ * @param isInternalImage Whether the error is from an internal image (S3) or external image
256+ */
257+ function handleImageError (
258+ e : any ,
259+ res : ServerResponse ,
260+ isInternalImage : boolean ,
261+ ) {
262+ let originalStatus = e . statusCode || e . $metadata ?. httpStatusCode || 500 ;
263+ let message = e . message || "Failed to process image request" ;
264+
265+ // Special handling for S3 ListBucket permission errors
266+ // AWS SDK v3 nests error details deeply within the error object
267+ const isListBucketError =
268+ ( message . includes ( "s3:ListBucket" ) && message . includes ( "AccessDenied" ) ) ||
269+ e . error ?. message ?. includes ( "s3:ListBucket" ) ||
270+ ( e . Code === "AccessDenied" && e . Message ?. includes ( "s3:ListBucket" ) ) ;
271+
272+ if ( isListBucketError ) {
273+ message = "Image not found or access denied" ;
274+ // For S3 ListBucket errors, ensure we're using 403 (the actual AWS error)
275+ if ( originalStatus === 500 && e . $metadata ?. httpStatusCode === 403 ) {
276+ originalStatus = 403 ;
277+ }
278+
279+ // Log using IgnorableError to classify as client error
280+ const clientError = new IgnorableError ( message , originalStatus ) ;
281+ error ( "S3 ListBucket permission error" , clientError ) ;
282+ } else {
283+ // Log all other errors as client errors
284+ const clientError = new IgnorableError ( message , originalStatus ) ;
285+ error ( "Failed to process image" , clientError ) ;
286+ }
287+
288+ // For external images, throw if not ListBucket error
289+ // Next.js will preserve the status code for external images
290+ if ( ! isInternalImage && ! isListBucketError ) {
291+ const formattedError = new Error ( message ) ;
292+ // @ts -ignore: Add statusCode property to Error
293+ formattedError . statusCode = originalStatus >= 500 ? 400 : originalStatus ;
294+ throw formattedError ;
295+ }
296+
297+ // Different handling for internal vs external images
298+ const finalStatus = originalStatus >= 500 ? 400 : originalStatus ;
299+ res . statusCode = finalStatus ;
300+
301+ // For internal images, we want to trigger Next.js's "internal response invalid" message
302+ if ( isInternalImage ) {
303+ // For internal images, don't set Content-Type to trigger Next.js's default error handling
304+ // This should result in "url parameter is valid but internal response is invalid"
305+
306+ // Still include error details in headers for debugging only
307+ const errorMessage = isListBucketError ? "Access denied" : message ;
308+ res . setHeader ( "x-nextjs-internal-error" , errorMessage ) ;
309+ res . end ( ) ;
310+ } else {
311+ // For external images, maintain existing behavior with text/plain
312+ res . setHeader ( "Content-Type" , "text/plain" ) ;
313+
314+ if ( isListBucketError ) {
315+ res . end ( "Access denied" ) ;
316+ } else {
317+ res . end ( message ) ;
318+ }
319+ }
320+ }
321+
322+ // Pipes data from a writable stream to the server response
323+ function pipeStream (
324+ stream : Writable ,
325+ res : ServerResponse ,
326+ isInternalImage : boolean ,
327+ ) {
328+ stream
329+ . pipe ( res )
301330 . once ( "close" , ( ) => {
302331 res . statusCode = 200 ;
303332 res . end ( ) ;
304333 } )
305334 . once ( "error" , ( err ) => {
306- error ( "Failed to get image" , err ) ;
307- res . statusCode = 400 ;
308- res . end ( ) ;
335+ error ( "Error streaming image data" , err ) ;
336+ handleImageError ( err , res , isInternalImage ) ;
309337 } ) ;
310- } ;
338+ }
311339
340+ // Main handler logic with clearer error paths
312341 try {
313- // Case 1: remote image URL => download the image from the URL
342+ // EXTERNAL IMAGE HANDLING
314343 if ( url . href . toLowerCase ( ) . match ( / ^ h t t p s ? : \/ \/ / ) ) {
315- const request = https . get ( url , ( response ) => {
316- // Check for HTTP error status codes
317- if ( response . statusCode && response . statusCode >= 400 ) {
318- // Create an IgnorableError with appropriate status code
319- const clientError = new IgnorableError (
320- response . statusMessage || `HTTP error ${ response . statusCode } ` ,
321- response . statusCode ,
322- ) ;
323-
324- // Log the error using proper error logger to handle it correctly
325- error ( "Client error fetching image" , clientError , {
326- status : response . statusCode ,
327- statusText : response . statusMessage ,
328- url : url . href ,
329- } ) ;
330-
331- res . statusCode = response . statusCode ;
332- res . end ( ) ;
333- return ;
334- }
335-
336- // IncomingMessage is a Readable stream, not a Writable
337- // We need to pipe it directly to the response
338- response
339- . pipe ( res )
340- . once ( "close" , ( ) => {
341- if ( ! res . headersSent ) {
342- res . statusCode = 200 ;
343- }
344- res . end ( ) ;
345- } )
346- . once ( "error" , ( pipeErr : Error ) => {
347- const clientError = new IgnorableError (
348- `Error during image piping: ${ pipeErr . message } ` ,
349- 400 ,
350- ) ;
351- error ( "Failed to get image during piping" , clientError ) ;
352- if ( ! res . headersSent ) {
353- res . statusCode = 400 ;
354- }
355- res . end ( ) ;
356- } ) ;
357- } ) ;
358-
359- request . on ( "error" , ( err : Error & { code ?: string } ) => {
360- // For network errors, convert to appropriate error type based on error code
361- const isClientError =
362- err . code === "ENOTFOUND" || err . code === "ECONNREFUSED" ;
363- const statusCode = isClientError ? 404 : 400 ;
364-
365- // Create appropriate error type
366- const clientError = new IgnorableError (
367- err . message || `Error fetching image: ${ err . code || "unknown error" } ` ,
368- statusCode ,
369- ) ;
370-
371- // Log with error function but it will be handled properly based on error type
372- error ( "Error fetching image" , clientError , {
373- code : err . code ,
374- message : err . message ,
375- url : url . href ,
376- } ) ;
377-
378- res . statusCode = statusCode ;
379- res . end ( ) ;
380- } ) ;
344+ try {
345+ pipeStream ( https . get ( url ) , res , false ) ;
346+ } catch ( e : any ) {
347+ handleImageError ( e , res , false ) ;
348+ }
349+ return ;
381350 }
382- // Case 2: local image => download the image from S3
383- else {
384- // Download image from S3
385- // note: S3 expects keys without leading `/`
386351
352+ // INTERNAL IMAGE HANDLING (S3)
353+ try {
387354 const response = await loader . load ( url . href ) ;
388355
356+ // Handle empty response body
389357 if ( ! response . body ) {
390- throw new Error ( "Empty response body from the S3 request." ) ;
391- }
358+ const message = "Empty response body from the S3 request." ;
359+ const clientError = new IgnorableError ( message , 400 ) ;
360+ error ( "Empty response from S3" , clientError ) ;
392361
393- // @ts -ignore
394- pipeRes ( response . body , res ) ;
362+ res . statusCode = 400 ;
363+ res . setHeader ( "Content-Type" , "text/plain" ) ;
364+ res . end ( message ) ;
365+ return ;
366+ }
395367
396- // Respect the bucket file's content-type and cache-control
397- // imageOptimizer will use this to set the results.maxAge
368+ // Set headers from the response
398369 if ( response . contentType ) {
399370 res . setHeader ( "Content-Type" , response . contentType ) ;
400371 }
401372 if ( response . cacheControl ) {
402373 res . setHeader ( "Cache-Control" , response . cacheControl ) ;
403374 }
375+
376+ // Stream the image to the client
377+ // @ts -ignore
378+ pipeStream ( response . body , res , true ) ;
379+ } catch ( e : any ) {
380+ // Direct response for all internal image errors
381+ handleImageError ( e , res , true ) ;
404382 }
405383 } catch ( e : any ) {
406- // Use our centralized error classification function
407- const classifiedError = classifyError ( e ) ;
408-
409- // Log error with appropriate level based on error type
410- // This will automatically downgrade client errors to debug level
411- error ( "Failed to download image" , classifiedError ) ;
412-
413- // Since we're in a middleware (adapter) called by Next.js's image optimizer,
414- // we should throw the properly classified error to let Next.js handle it appropriately
415- // The Next.js image optimizer will use the status code and normalize the error message
416- throw classifiedError ;
384+ // Catch-all for any unexpected errors
385+ handleImageError ( e , res , true ) ;
417386 }
418387}
0 commit comments