Skip to content

Commit 2ab91a0

Browse files
committed
Clarify health endpoint route conflicts
1 parent e90f847 commit 2ab91a0

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

packages/react-on-rails-pro-node-renderer/src/worker.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import packageJson from './shared/packageJson.js';
3030
import { buildConfig, Config, getConfig } from './shared/configBuilder.js';
3131
import fileExistsAsync from './shared/fileExistsAsync.js';
3232
import { runRscPeerCompatibilityCheck } from './shared/runRscPeerCompatibilityCheck.js';
33-
import type { FastifyReply } from './worker/types.js';
33+
import type { FastifyInstance, FastifyReply } from './worker/types.js';
3434
import { performRequestPrechecks } from './worker/requestPrechecks.js';
3535
import { type AuthBody, authenticate } from './worker/authHandler.js';
3636
import {
@@ -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+
8790
function 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

107110
const 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+
109148
function 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
}

packages/react-on-rails-pro-node-renderer/tests/healthEndpoints.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import formAutoContent from 'form-auto-content';
1717
import { createReadStream } from 'fs-extra';
1818
// eslint-disable-next-line import/no-relative-packages
1919
import packageJson from '../package.json';
20-
import worker, { disableHttp2 } from '../src/worker';
20+
import worker, { configureFastify, disableHttp2 } from '../src/worker';
21+
import { __resetFastifyConfigFunctionsForTest } from '../src/worker/fastifyConfig';
2122
import { BUNDLE_TIMESTAMP, getFixtureBundle, resetForTest, serverBundleCachePath } from './helper';
2223

2324
const testName = 'healthEndpoints';
@@ -60,12 +61,14 @@ describe('built-in health endpoints', () => {
6061

6162
beforeEach(async () => {
6263
delete process.env.RENDERER_ENABLE_HEALTH_ENDPOINTS;
64+
__resetFastifyConfigFunctionsForTest();
6365
await resetForTest(testName);
6466
});
6567

6668
afterEach(async () => {
6769
await app?.close();
6870
app = undefined;
71+
__resetFastifyConfigFunctionsForTest();
6972
});
7073

7174
afterAll(async () => {
@@ -107,6 +110,7 @@ describe('built-in health endpoints', () => {
107110

108111
const readyRes = await app.inject().get('/ready').end();
109112
expect(readyRes.statusCode).toBe(503);
113+
expect(readyRes.headers['retry-after']).toBe('5');
110114
expect(JSON.parse(readyRes.payload)).toEqual({ status: 'waiting_for_bundle' });
111115
});
112116

@@ -116,6 +120,7 @@ describe('built-in health endpoints', () => {
116120
// No bundle compiled into the VM pool yet: not ready.
117121
const notReadyRes = await app.inject().get('/ready').end();
118122
expect(notReadyRes.statusCode).toBe(503);
123+
expect(notReadyRes.headers['retry-after']).toBe('5');
119124
expect(JSON.parse(notReadyRes.payload)).toEqual({ status: 'waiting_for_bundle' });
120125

121126
// First render request uploads and compiles the bundle.
@@ -141,4 +146,16 @@ describe('built-in health endpoints', () => {
141146
expect(healthAfter.statusCode).toBe(200);
142147
expect(JSON.parse(healthAfter.payload)).toEqual({ status: 'ok' });
143148
});
149+
150+
test('reports a migration hint when a custom health route conflicts with built-in endpoints', () => {
151+
configureFastify((fastifyApp) => {
152+
fastifyApp.get('/health', (_req, res) => {
153+
res.send({ status: 'legacy' });
154+
});
155+
});
156+
157+
expect(() => createWorker({ enableHealthEndpoints: true })).toThrow(
158+
/enableHealthEndpoints registers built-in GET \/health/,
159+
);
160+
});
144161
});

0 commit comments

Comments
 (0)