@@ -30,7 +30,7 @@ import packageJson from './shared/packageJson.js';
3030import { buildConfig , Config , getConfig } from './shared/configBuilder.js' ;
3131import fileExistsAsync from './shared/fileExistsAsync.js' ;
3232import { runRscPeerCompatibilityCheck } from './shared/runRscPeerCompatibilityCheck.js' ;
33- import type { FastifyReply } from './worker/types.js' ;
33+ import type { FastifyInstance , FastifyReply } from './worker/types.js' ;
3434import { performRequestPrechecks } from './worker/requestPrechecks.js' ;
3535import { type AuthBody , authenticate } from './worker/authHandler.js' ;
3636import {
@@ -84,6 +84,9 @@ declare module 'fastify' {
8484 }
8585}
8686
87+ const HEALTH_ENDPOINT_ROUTES = [ '/health' , '/ready' ] as const ;
88+ const FASTIFY_DUPLICATED_ROUTE_ERROR_CODE = 'FST_ERR_DUPLICATED_ROUTE' ;
89+
8790function setHeaders ( headers : ResponseResult [ 'headers' ] , res : FastifyReply ) {
8891 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- fixing it with `void` just violates no-void
8992 Object . entries ( headers ) . forEach ( ( [ key , header ] ) => res . header ( key , header ) ) ;
@@ -106,6 +109,42 @@ const setResponse = async (result: ResponseResult, res: FastifyReply) => {
106109
107110const isAsset = ( value : unknown ) : value is Asset => ( value as { type ?: string } ) . type === 'asset' ;
108111
112+ function conflictingHealthEndpointPath ( error : unknown ) : ( typeof HEALTH_ENDPOINT_ROUTES ) [ number ] | undefined {
113+ if ( typeof error !== 'object' || error === null ) {
114+ return undefined ;
115+ }
116+
117+ const { code, message } = error as { code ?: unknown ; message ?: unknown } ;
118+ if ( code !== FASTIFY_DUPLICATED_ROUTE_ERROR_CODE || typeof message !== 'string' ) {
119+ return undefined ;
120+ }
121+
122+ return HEALTH_ENDPOINT_ROUTES . find ( ( routePath ) => message . includes ( `route '${ routePath } '` ) ) ;
123+ }
124+
125+ function applyFastifyConfigWithHealthEndpointMigrationHint (
126+ app : FastifyInstance ,
127+ enableHealthEndpoints : boolean ,
128+ ) {
129+ try {
130+ applyFastifyConfigFunctions ( app ) ;
131+ } catch ( error ) {
132+ const conflictingPath = enableHealthEndpoints ? conflictingHealthEndpointPath ( error ) : undefined ;
133+ if ( conflictingPath ) {
134+ const originalMessage = error instanceof Error ? error . message : String ( error ) ;
135+ const message =
136+ `enableHealthEndpoints registers built-in GET ${ conflictingPath } , but a configureFastify callback ` +
137+ `already registered that route. Remove or rename the custom ${ conflictingPath } route when migrating ` +
138+ 'to the built-in health endpoints. See docs/oss/building-features/node-renderer/health-checks.md.' ;
139+
140+ log . error ( { err : error , route : conflictingPath } , message ) ;
141+ throw new Error ( `${ message } Original Fastify error: ${ originalMessage } ` ) ;
142+ }
143+
144+ throw error ;
145+ }
146+ }
147+
109148function assertAsset ( value : unknown , key : string ) : asserts value is Asset {
110149 if ( ! isAsset ( value ) ) {
111150 throw new Error ( `React On Rails Error: Expected an asset for key: ${ key } ` ) ;
@@ -681,7 +720,7 @@ export default function run(config: Partial<Config>) {
681720 if ( hasAnyVMContext ( ) ) {
682721 res . send ( { status : 'ready' } ) ;
683722 } else {
684- res . status ( 503 ) . send ( { status : 'waiting_for_bundle' } ) ;
723+ res . status ( 503 ) . header ( 'Retry-After' , '5' ) . send ( { status : 'waiting_for_bundle' } ) ;
685724 }
686725 } ) ;
687726 }
@@ -703,7 +742,7 @@ export default function run(config: Partial<Config>) {
703742
704743 // Integration hooks registered before the worker loads are applied here, immediately after
705744 // listen() is scheduled and before Fastify finishes booting.
706- applyFastifyConfigFunctions ( app ) ;
745+ applyFastifyConfigWithHealthEndpointMigrationHint ( app , enableHealthEndpoints ) ;
707746
708747 return app ;
709748}
0 commit comments