@@ -16,7 +16,7 @@ import cookie from 'cookie'
1616import { getProperty } from 'dot-prop'
1717import generateETag from 'etag'
1818import getAvailablePort from 'get-port'
19- import httpProxy from 'http-proxy'
19+ import httpProxy , { type ServerOptions } from 'http-proxy'
2020import { createProxyMiddleware } from 'http-proxy-middleware'
2121import { jwtDecode , type JwtPayload } from 'jwt-decode'
2222import { locatePath } from 'locate-path'
@@ -43,9 +43,25 @@ import { NFFunctionName, NFFunctionRoute, NFRequestID, headersForPath, parseHead
4343import { generateRequestID } from './request-id.js'
4444import { createRewriter , onChanges } from './rules-proxy.js'
4545import { signRedirect } from './sign-redirect.js'
46- import type { Rewriter , ServerSettings } from './types.js'
46+ import type { Rewriter , ExtraServerOptions , ServerSettings } from './types.js'
4747import { ClientRequest , IncomingMessage } from 'node:http'
4848
49+ declare module 'http' {
50+ // This is only necessary because we're attaching custom junk to the `req` given to us
51+ // by the `http-proxy` module. Since it in turn imports its request object type from `http`,
52+ // we have no choice but to augment the `http` module itself globally.
53+ // NOTE: to be extra clear, this is *augmenting* the existing type:
54+ // https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces.
55+ interface IncomingMessage {
56+ originalBody ?: Buffer | null
57+ protocol ?: string
58+ hostname ?: string
59+ __expectHeader ?: string
60+ alternativePaths ?: string [ ]
61+ proxyOptions : ServerOptions
62+ }
63+ }
64+
4965const gunzip = util . promisify ( zlib . gunzip )
5066const gzip = util . promisify ( zlib . gzip )
5167const brotliDecompress = util . promisify ( zlib . brotliDecompress )
@@ -66,6 +82,18 @@ const setShouldGenerateETag = (req: IncomingMessage, shouldGenerateETag: ShouldG
6682 req [ shouldGenerateETagSymbol ] = shouldGenerateETag
6783}
6884
85+ type ExtendedServerOptions = ServerOptions | ExtraServerOptions
86+
87+ const getExtraServerOption = (
88+ options : ExtendedServerOptions ,
89+ name : keyof ExtendedServerOptions ,
90+ ) : ExtendedServerOptions [ typeof name ] => {
91+ if ( name in options ) {
92+ return options [ name ]
93+ }
94+ return
95+ }
96+
6997const decompressResponseBody = async function ( body : Buffer , contentEncoding = '' ) : Promise < Buffer > {
7098 switch ( contentEncoding ) {
7199 case 'gzip' :
@@ -158,7 +186,7 @@ const getStatic = async function (pathname: string, publicFolder: string): Promi
158186 return `/${ path . relative ( publicFolder , file ) } `
159187}
160188
161- const isEndpointExists = async function ( endpoint : string , origin : string ) {
189+ const isEndpointExists = async function ( endpoint : string , origin ? : string | undefined ) {
162190 const url = new URL ( endpoint , origin )
163191 try {
164192 const res = await fetch ( url , { method : 'HEAD' } )
@@ -274,19 +302,21 @@ const serveRedirect = async function ({
274302 res,
275303 siteInfo,
276304} : {
277- options : IncomingMessage [ 'proxyOptions' ]
305+ options : ExtendedServerOptions
278306 req : IncomingMessage
279307 res : ServerResponse
280308 match : Match | null
281309} & Record < string , $TSFixMe > ) {
282310 if ( ! match ) return proxy . web ( req , res , options )
283311
284- options = options || req . proxyOptions || { }
285- options . match = null
312+ options = {
313+ ...( options ?? req . proxyOptions ) ,
314+ match : null ,
315+ }
286316
287317 if ( match . force404 ) {
288318 res . writeHead ( 404 )
289- res . end ( await render404 ( options . publicFolder ) )
319+ res . end ( await render404 ( getExtraServerOption ( options , ' publicFolder' ) ) )
290320 return
291321 }
292322
@@ -314,11 +344,11 @@ const serveRedirect = async function ({
314344 }
315345 }
316346
317- if ( isFunction ( options . functionsPort , req . url ?? '' ) ) {
347+ if ( isFunction ( getExtraServerOption ( options , ' functionsPort' ) , req . url ?? '' ) ) {
318348 return proxy . web ( req , res , { target : options . functionsServer } )
319349 }
320350
321- const urlForAddons = getAddonUrl ( options . addonsUrls ?? { } , req )
351+ const urlForAddons = getAddonUrl ( getExtraServerOption ( options , ' addonsUrls' ) ?? { } , req )
322352 if ( urlForAddons ) {
323353 return handleAddonUrl ( { req, res, addonUrl : urlForAddons } )
324354 }
@@ -354,9 +384,15 @@ const serveRedirect = async function ({
354384 if ( ( jwtValue . exp || 0 ) < Math . round ( Date . now ( ) / MILLISEC_TO_SEC ) ) {
355385 console . warn ( NETLIFYDEVWARN , 'Expired JWT provided in request' , req . url )
356386 } else {
357- const presentedRoles = getProperty ( jwtValue , options . jwtRolePath ) || [ ]
387+ // I think through some circuitous callback logic `options.jwtRolePath` is guaranteed to
388+ // be defined at this point, but I don't think it's possible to convince TS of this.
389+ const presentedRoles = getProperty ( jwtValue , getExtraServerOption ( options , 'jwtRolePath' ) ) ?? [ ]
358390 if ( ! Array . isArray ( presentedRoles ) ) {
359- console . warn ( NETLIFYDEVWARN , `Invalid roles value provided in JWT ${ options . jwtRolePath } ` , presentedRoles )
391+ console . warn (
392+ NETLIFYDEVWARN ,
393+ `Invalid roles value provided in JWT ${ getExtraServerOption ( options , 'jwtRolePath' ) } ` ,
394+ presentedRoles ,
395+ )
360396 res . writeHead ( 400 )
361397 res . end ( 'Invalid JWT provided. Please see logs for more info.' )
362398 return
@@ -375,12 +411,18 @@ const serveRedirect = async function ({
375411 match . proxyHeaders &&
376412 Object . entries ( match . proxyHeaders ) . some ( ( [ key , val ] ) => key . toLowerCase ( ) === 'x-nf-hidden-proxy' && val === 'true' )
377413
378- const staticFile = await getStatic ( decodeURIComponent ( reqUrl . pathname ) , options . publicFolder ?? '' )
414+ const staticFile = await getStatic (
415+ decodeURIComponent ( reqUrl . pathname ) ,
416+ getExtraServerOption ( options , 'publicFolder' ) ?? '' ,
417+ )
379418 const endpointExists =
380419 ! staticFile &&
381420 ! isHiddenProxy &&
382421 process . env . NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS &&
383- ( await isEndpointExists ( decodeURIComponent ( reqUrl . pathname ) , options . target ) )
422+ // @ts -expect-error(serhalp) -- TODO verify if the intent is that `options.target` is
423+ // always a string (if so, use `typeof` to only pass strings), or if this is implicitly
424+ // relying on built-in coercion to a string of the various support target URL-ish types.
425+ ( await isEndpointExists ( decodeURIComponent ( reqUrl . pathname ) , getExtraServerOption ( options , 'target' ) ) )
384426 if ( staticFile || endpointExists ) {
385427 const pathname = staticFile || reqUrl . pathname
386428 req . url = encodeURI ( pathname ) + reqUrl . search
@@ -390,7 +432,7 @@ const serveRedirect = async function ({
390432 }
391433 }
392434
393- if ( match . force || ! staticFile || ! options . framework || req . method === 'POST' ) {
435+ if ( match . force || ! staticFile || ! getExtraServerOption ( options , ' framework' ) || req . method === 'POST' ) {
394436 // construct destination URL from redirect rule match
395437 const dest = new URL ( match . to , `${ reqUrl . protocol } //${ reqUrl . host } ` )
396438
@@ -439,17 +481,18 @@ const serveRedirect = async function ({
439481 ! isInternal ( destURL ) &&
440482 ( ct . endsWith ( '/x-www-form-urlencoded' ) || ct === 'multipart/form-data' )
441483 ) {
442- return proxy . web ( req , res , { target : options . functionsServer } )
484+ return proxy . web ( req , res , { target : getExtraServerOption ( options , ' functionsServer' ) } )
443485 }
444486
445- const destStaticFile = await getStatic ( dest . pathname , options . publicFolder ?? '' )
487+ const destStaticFile = await getStatic ( dest . pathname , getExtraServerOption ( options , 'functionsServer' ) ?? '' )
446488 const matchingFunction =
447489 functionsRegistry &&
448490 ( await functionsRegistry . getFunctionForURLPath ( destURL , req . method , ( ) => Boolean ( destStaticFile ) ) )
449491 let statusValue : number | undefined
450492 if (
451493 match . force ||
452- ( ! staticFile && ( ( ! options . framework && destStaticFile ) || isInternal ( destURL ) || matchingFunction ) )
494+ ( ! staticFile &&
495+ ( ( ! getExtraServerOption ( options , 'framework' ) && destStaticFile ) || isInternal ( destURL ) || matchingFunction ) )
453496 ) {
454497 req . url = destStaticFile ? destStaticFile + dest . search : destURL
455498 const { status } = match
@@ -468,12 +511,12 @@ const serveRedirect = async function ({
468511 req . headers [ 'x-netlify-original-pathname' ] = url . pathname
469512 req . headers [ 'x-netlify-original-search' ] = url . search
470513
471- return proxy . web ( req , res , { headers : functionHeaders , target : options . functionsServer } )
514+ return proxy . web ( req , res , { headers : functionHeaders , target : getExtraServerOption ( options , ' functionsServer' ) } )
472515 }
473516 if ( isImageRequest ( req ) ) {
474517 return imageProxy ( req , res )
475518 }
476- const addonUrl = getAddonUrl ( options . addonsUrls ?? { } , req )
519+ const addonUrl = getAddonUrl ( getExtraServerOption ( options , ' addonsUrls' ) ?? { } , req )
477520 if ( addonUrl ) {
478521 return handleAddonUrl ( { req, res, addonUrl } )
479522 }
@@ -585,7 +628,7 @@ const initializeProxy = async function ({
585628 // The request has failed but we might still have a matching redirect
586629 // rule (without `force`) that should kick in. This is how we mimic the
587630 // file shadowing behavior from the CDN.
588- if ( req . proxyOptions && req . proxyOptions . match ) {
631+ if ( req . proxyOptions ? .match ) {
589632 return serveRedirect ( {
590633 // We don't want to match functions at this point because any redirects
591634 // to functions will have already been processed, so we don't supply a
0 commit comments