Skip to content

Commit 217b5f2

Browse files
authored
Add a built-in health check route (Shopify#1375)
1 parent 3364225 commit 217b5f2

File tree

9 files changed

+192
-102
lines changed

9 files changed

+192
-102
lines changed

Diff for: .changeset/loud-timers-pay.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/hydrogen': patch
3+
---
4+
5+
Add a built-in healthcheck route available at `/__health`. It responds with a 200 and no body. Also suppresses server logs for built-in routes like healthcheck and analytics.

Diff for: docs/framework/routes.md

+10-2
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ By default, when a user hovers or focuses on the link for more than 100ms, a pre
102102

103103
You can extend dynamic routes to catch all paths by adding an ellipsis (...) inside the brackets. For example, `/routes/example/[...handle].server.jsx` will match `/example/a` and `/example/a/b`.
104104

105+
### Built-in routes
106+
107+
Hydrogen provides the following built-in routes:
108+
109+
- `/__health` - A health check route that responds with a 200 status and no body. You can use this route within your infrastructure to verify that your app is healthy and able to respond to requests.
110+
- `/__rsc` - An internal route used to re-render server components. It's called by the Hydrogen frontend when the route changes, or when server props change. You should never need to manually request this route.
111+
- `/__event` - An internal route used to save client observability events. You should never need to manually request this route.
112+
105113
### Example
106114

107115
The following example shows how to obtain catch all routes data using `location.pathname`:
@@ -240,8 +248,8 @@ export default function Page() {
240248

241249
Server components placed in the `src/routes` directory receive the following special props that you can use to create custom experiences:
242250

243-
| Prop | Type |
244-
| ---------- | ------------------------- |
251+
| Prop | Type |
252+
| ---------- | ------------------ |
245253
| `request` | `HydrogenRequest` |
246254
| `response` | `HydrogenResponse` |
247255

Diff for: packages/hydrogen/src/entry-server.tsx

+24-10
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ import {
3939
createFromReadableStream,
4040
bufferReadableStream,
4141
} from './streaming.server';
42-
import {RSC_PATHNAME, EVENT_PATHNAME, EVENT_PATHNAME_REGEX} from './constants';
42+
import {RSC_PATHNAME} from './constants';
4343
import {stripScriptsFromTemplate} from './utilities/template';
4444
import {setLogger, RenderType} from './utilities/log/log';
4545
import {Analytics} from './foundation/Analytics/Analytics.server';
46-
import {ServerAnalyticsRoute} from './foundation/Analytics/ServerAnalyticsRoute.server';
4746
import {getSyncSessionApi} from './foundation/session/session';
4847
import {parseJSON} from './utilities/parse';
4948
import {htmlEncode} from './utilities';
5049
import {splitCookiesString} from 'set-cookie-parser';
50+
import {getBuiltInRoute} from './foundation/BuiltInRoutes/BuiltInRoutes';
5151

5252
declare global {
5353
// This is provided by a Vite plugin
@@ -111,14 +111,26 @@ export const renderHydrogen = (App: any) => {
111111
request.ctx.runtime = context;
112112
setCache(cache);
113113

114-
if (
115-
url.pathname === EVENT_PATHNAME ||
116-
EVENT_PATHNAME_REGEX.test(url.pathname)
117-
) {
118-
return ServerAnalyticsRoute(
114+
const builtInRouteResource = getBuiltInRoute(url);
115+
116+
if (builtInRouteResource) {
117+
const apiResponse = await renderApiRoute(
119118
request,
120-
hydrogenConfig.serverAnalyticsConnectors
119+
{
120+
resource: builtInRouteResource,
121+
params: {},
122+
hasServerComponent: false,
123+
},
124+
hydrogenConfig,
125+
{
126+
session: sessionApi,
127+
suppressLog: true,
128+
}
121129
);
130+
131+
return apiResponse instanceof Request
132+
? handleRequest(apiResponse, options)
133+
: apiResponse;
122134
}
123135

124136
const isRSCRequest = url.pathname === RSC_PATHNAME;
@@ -133,8 +145,10 @@ export const renderHydrogen = (App: any) => {
133145
const apiResponse = await renderApiRoute(
134146
request,
135147
apiRoute,
136-
hydrogenConfig.shopify,
137-
sessionApi
148+
hydrogenConfig,
149+
{
150+
session: sessionApi,
151+
}
138152
);
139153

140154
return apiResponse instanceof Request

Diff for: packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.server.tsx renamed to packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import type {ServerAnalyticsConnector} from '../../types';
1+
import type {ResolvedHydrogenConfig} from '../../types';
22
import {log} from '../../utilities/log';
33

4-
export function ServerAnalyticsRoute(
4+
export async function ServerAnalyticsRoute(
55
request: Request,
6-
serverAnalyticsConnectors?: Array<ServerAnalyticsConnector>
6+
{hydrogenConfig}: {hydrogenConfig: ResolvedHydrogenConfig}
77
) {
88
const requestHeader = request.headers;
99
const requestUrl = request.url;
10+
const serverAnalyticsConnectors = hydrogenConfig.serverAnalyticsConnectors;
1011

1112
if (requestHeader.get('Content-Length') === '0') {
1213
serverAnalyticsConnectors?.forEach((connector) => {
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {ServerAnalyticsRoute} from '../ServerAnalyticsRoute.server';
1+
import {ResolvedHydrogenConfig} from '../../../types';
2+
import {ServerAnalyticsRoute} from '../ServerAnalyticsRoute';
23

34
const createRequest = () => {
45
return new Request('__event', {
@@ -9,37 +10,47 @@ const createRequest = () => {
910
};
1011

1112
describe('Analytics - ServerAnalyticsRoute', () => {
12-
it('should return a 200 response', () => {
13-
const response = ServerAnalyticsRoute(createRequest());
13+
it('should return a 200 response', async () => {
14+
const response = await ServerAnalyticsRoute(createRequest(), {
15+
hydrogenConfig: {} as ResolvedHydrogenConfig,
16+
});
1417
expect(response.status).toEqual(200);
1518
});
1619

17-
it('should delegate request to a server analytics connector', () => {
20+
it('should delegate request to a server analytics connector', async () => {
1821
const mockServerAnalyticsConnector = jest.fn();
1922
const request = createRequest();
20-
const response = ServerAnalyticsRoute(request, [
21-
{
22-
request: mockServerAnalyticsConnector,
23-
},
24-
]);
23+
const response = await ServerAnalyticsRoute(request, {
24+
hydrogenConfig: {
25+
serverAnalyticsConnectors: [
26+
{
27+
request: mockServerAnalyticsConnector,
28+
},
29+
],
30+
} as unknown as ResolvedHydrogenConfig,
31+
});
2532

2633
expect(response.status).toEqual(200);
2734
expect(mockServerAnalyticsConnector).toHaveBeenCalled();
2835
expect(mockServerAnalyticsConnector.mock.calls[0][0]).toEqual(request.url);
2936
});
3037

31-
it('should delegate request to multiple server analytics connectors', () => {
38+
it('should delegate request to multiple server analytics connectors', async () => {
3239
const mockServerAnalyticsConnector1 = jest.fn();
3340
const mockServerAnalyticsConnector2 = jest.fn();
3441
const request = createRequest();
35-
const response = ServerAnalyticsRoute(request, [
36-
{
37-
request: mockServerAnalyticsConnector1,
38-
},
39-
{
40-
request: mockServerAnalyticsConnector2,
41-
},
42-
]);
42+
const response = await ServerAnalyticsRoute(request, {
43+
hydrogenConfig: {
44+
serverAnalyticsConnectors: [
45+
{
46+
request: mockServerAnalyticsConnector1,
47+
},
48+
{
49+
request: mockServerAnalyticsConnector2,
50+
},
51+
],
52+
} as unknown as ResolvedHydrogenConfig,
53+
});
4354

4455
expect(response.status).toEqual(200);
4556
expect(mockServerAnalyticsConnector1).toHaveBeenCalled();
@@ -48,66 +59,70 @@ describe('Analytics - ServerAnalyticsRoute', () => {
4859
expect(mockServerAnalyticsConnector2.mock.calls[0][0]).toEqual(request.url);
4960
});
5061

51-
it('should delegate json request', async () => {
52-
return new Promise<void>((resolve) => {
53-
const testRequest = new Request('__event', {
54-
method: 'POST',
55-
headers: {
56-
'Content-Type': 'application/json',
57-
},
58-
body: JSON.stringify({
59-
test: '123',
60-
}),
62+
it('should delegate json request', async (done) => {
63+
const testRequest = new Request('__event', {
64+
method: 'POST',
65+
headers: {
66+
'Content-Type': 'application/json',
67+
},
68+
body: JSON.stringify({
69+
test: '123',
70+
}),
71+
});
72+
const mockServerAnalyticsConnector = (
73+
requestUrl: string,
74+
requestHeader: Headers,
75+
data?: any,
76+
type?: string
77+
): void => {
78+
expect(requestUrl).toEqual(testRequest.url);
79+
expect(requestHeader).toEqual(testRequest.headers);
80+
expect(data).toEqual({
81+
test: '123',
6182
});
62-
const mockServerAnalyticsConnector = (
63-
requestUrl: string,
64-
requestHeader: Headers,
65-
data?: any,
66-
type?: string
67-
): void => {
68-
expect(requestUrl).toEqual(testRequest.url);
69-
expect(requestHeader).toEqual(testRequest.headers);
70-
expect(data).toEqual({
71-
test: '123',
72-
});
73-
expect(type).toEqual('json');
74-
resolve();
75-
};
76-
const response = ServerAnalyticsRoute(testRequest, [
77-
{
78-
request: mockServerAnalyticsConnector,
79-
},
80-
]);
81-
82-
expect(response.status).toEqual(200);
83+
expect(type).toEqual('json');
84+
done();
85+
};
86+
const response = await ServerAnalyticsRoute(testRequest, {
87+
hydrogenConfig: {
88+
serverAnalyticsConnectors: [
89+
{
90+
request: mockServerAnalyticsConnector,
91+
},
92+
],
93+
} as unknown as ResolvedHydrogenConfig,
8394
});
84-
});
8595

86-
it('should delegate text request', async () => {
87-
return new Promise<void>((resolve) => {
88-
const testRequest = new Request('__event', {
89-
method: 'POST',
90-
body: 'test123',
91-
});
92-
const mockServerAnalyticsConnector = (
93-
requestUrl: string,
94-
requestHeader: Headers,
95-
data?: any,
96-
type?: string
97-
): void => {
98-
expect(requestUrl).toEqual(testRequest.url);
99-
expect(requestHeader).toEqual(testRequest.headers);
100-
expect(data).toEqual('test123');
101-
expect(type).toEqual('text');
102-
resolve();
103-
};
104-
const response = ServerAnalyticsRoute(testRequest, [
105-
{
106-
request: mockServerAnalyticsConnector,
107-
},
108-
]);
96+
expect(response.status).toEqual(200);
97+
});
10998

110-
expect(response.status).toEqual(200);
99+
it('should delegate text request', async (done) => {
100+
const testRequest = new Request('__event', {
101+
method: 'POST',
102+
body: 'test123',
103+
});
104+
const mockServerAnalyticsConnector = (
105+
requestUrl: string,
106+
requestHeader: Headers,
107+
data?: any,
108+
type?: string
109+
): void => {
110+
expect(requestUrl).toEqual(testRequest.url);
111+
expect(requestHeader).toEqual(testRequest.headers);
112+
expect(data).toEqual('test123');
113+
expect(type).toEqual('text');
114+
done();
115+
};
116+
const response = await ServerAnalyticsRoute(testRequest, {
117+
hydrogenConfig: {
118+
serverAnalyticsConnectors: [
119+
{
120+
request: mockServerAnalyticsConnector,
121+
},
122+
],
123+
} as unknown as ResolvedHydrogenConfig,
111124
});
125+
126+
expect(response.status).toEqual(200);
112127
});
113128
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {EVENT_PATHNAME, EVENT_PATHNAME_REGEX} from '../../constants';
2+
import {ResourceGetter} from '../../utilities/apiRoutes';
3+
import {ServerAnalyticsRoute} from '../Analytics/ServerAnalyticsRoute';
4+
import {HealthCheck} from './healthCheck';
5+
6+
type BuiltInRoute = {
7+
pathname?: string;
8+
regex?: RegExp;
9+
resource: ResourceGetter;
10+
};
11+
12+
const builtInRoutes: Array<BuiltInRoute> = [
13+
{
14+
pathname: EVENT_PATHNAME,
15+
regex: EVENT_PATHNAME_REGEX,
16+
resource: ServerAnalyticsRoute,
17+
},
18+
{
19+
pathname: '/__health',
20+
resource: HealthCheck,
21+
},
22+
];
23+
24+
export function getBuiltInRoute(url: URL): ResourceGetter | null {
25+
for (const route of builtInRoutes) {
26+
if (
27+
url.pathname === route.pathname ||
28+
(route.regex && route.regex.test(url.pathname))
29+
) {
30+
return route.resource;
31+
}
32+
}
33+
return null;
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function HealthCheck() {
2+
return new Response(null, {status: 200});
3+
}

0 commit comments

Comments
 (0)