Skip to content

Commit b62ae96

Browse files
authored
Simulate geoip server header in local development (#103597)
* Simulate geoip server header in local development * Bootstrap in development condition instead of feature flag See: #103597 (comment) * Flatten to remove IIFE See: #103597 (comment) * Remove unnecessary async * Avoid refetch on failed geolocation request * Mock fetch implementation to resolve fetch completed See: #103597 (comment)
1 parent 9f6cc62 commit b62ae96

File tree

3 files changed

+149
-0
lines changed

3 files changed

+149
-0
lines changed

client/server/boot/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import analytics from 'calypso/server/lib/analytics';
1010
import loggerMiddleware from 'calypso/server/middleware/logger';
1111
import pages from 'calypso/server/pages';
1212
import pwa from 'calypso/server/pwa';
13+
import geoipHeaderMiddleware from '../middleware/geoip-header.ts';
1314

1415
/**
1516
* Returns the server HTTP request handler "app".
@@ -36,6 +37,10 @@ export default function setup() {
3637
if ( 'development' === process.env.NODE_ENV ) {
3738
require( 'calypso/server/bundler' )( app );
3839

40+
// Production servers forward a request header with the user's country code. To ensure a
41+
// consistent experience, we simulate this in local development with a custom middleware.
42+
app.use( geoipHeaderMiddleware() );
43+
3944
// When mocking WordPress.com to point locally, wordpress.com/wp-login.php will hit Calypso creating an infinite loop.
4045
// redirect traffic to de.wordpress.com to hit the real backend and prevent a loop.
4146
// `de.wordpress.com` accepts POST requests to `/wp-login.php` exactly like `wordpress.com`.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type RequestHandler } from 'express';
2+
import { getLogger } from 'calypso/server/lib/logger';
3+
4+
/**
5+
* Adds the `x-geoip-country-code` header to the request. This is used to simulate the header added
6+
* in production environments, which is not available in all environments.
7+
* @returns A middleware function that adds the `x-geoip-country-code` header to the request.
8+
*/
9+
export default function (): RequestHandler {
10+
// To avoid delays on handling individual requests, fetch the geolocation once during server
11+
// startup. This will retrieve the geolocation of the server (not the client), but it's expected
12+
// that the middleware would be enabled on a developer's own machine.
13+
let countryCode: string | undefined;
14+
let countryCodePromise: Promise< string | undefined > | undefined;
15+
16+
async function getGeoCountryCode() {
17+
if ( ! countryCode ) {
18+
if ( ! countryCodePromise ) {
19+
countryCodePromise = fetch( 'https://public-api.wordpress.com/geo/' )
20+
.then( ( response ) => response.json() )
21+
.then( ( { country_short } ) => country_short )
22+
// Support offline development and set an expectation that the header is not
23+
// always available by quietly skipping if the request fails.
24+
.catch( () => {
25+
getLogger().info( 'Failed to fetch geolocation' );
26+
} );
27+
}
28+
29+
countryCode = await countryCodePromise;
30+
}
31+
32+
return countryCode;
33+
}
34+
35+
// Prime the cache when the middleware is initialized.
36+
getGeoCountryCode();
37+
38+
return async ( req, _res, next ) => {
39+
const countryCode = await getGeoCountryCode();
40+
if ( countryCode ) {
41+
req.headers[ 'x-geoip-country-code' ] = countryCode;
42+
}
43+
44+
next();
45+
};
46+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { type Request, type Response } from 'express';
2+
import middlewareGeoipHeader from '../geoip-header';
3+
4+
const mockInfo = jest.fn();
5+
6+
jest.mock( 'calypso/server/lib/logger', () => ( {
7+
getLogger: jest.fn().mockImplementation( () => ( { info: mockInfo } ) ),
8+
} ) );
9+
10+
describe( 'geoip-header middleware', () => {
11+
let request: Request;
12+
let response: Response;
13+
14+
beforeEach( () => {
15+
jest.clearAllMocks();
16+
jest.spyOn( global, 'fetch' );
17+
request = { headers: {} } as Request;
18+
response = {} as Response;
19+
} );
20+
21+
it( 'should fetch country code on initialization', async () => {
22+
const { promise, resolve } = Promise.withResolvers< void >();
23+
( global.fetch as jest.Mock ).mockImplementation( () => {
24+
resolve();
25+
return Promise.resolve( {
26+
json: () => Promise.resolve( { country_short: 'US' } ),
27+
} );
28+
} );
29+
30+
middlewareGeoipHeader();
31+
32+
await promise;
33+
expect( global.fetch ).toHaveBeenCalledTimes( 1 );
34+
expect( global.fetch ).toHaveBeenCalledWith( 'https://public-api.wordpress.com/geo/' );
35+
} );
36+
37+
it( 'should add country code header to request when available', async () => {
38+
( global.fetch as jest.Mock ).mockResolvedValueOnce( {
39+
json: () => Promise.resolve( { country_short: 'US' } ),
40+
} );
41+
const next = jest.fn();
42+
43+
const middleware = middlewareGeoipHeader();
44+
45+
await middleware( request, response, next );
46+
47+
expect( request.headers[ 'x-geoip-country-code' ] ).toBe( 'US' );
48+
expect( next ).toHaveBeenCalled();
49+
} );
50+
51+
it( 'should not add header when country code is not available', async () => {
52+
( global.fetch as jest.Mock ).mockRejectedValueOnce( new Error( 'Network error' ) );
53+
const next = jest.fn();
54+
55+
const middleware = middlewareGeoipHeader();
56+
57+
await middleware( request, response, next );
58+
59+
expect( request.headers[ 'x-geoip-country-code' ] ).toBeUndefined();
60+
expect( next ).toHaveBeenCalled();
61+
expect( mockInfo ).toHaveBeenCalledWith( 'Failed to fetch geolocation' );
62+
} );
63+
64+
it( 'should avoid duplicate requests when country code is available', async () => {
65+
( global.fetch as jest.Mock ).mockResolvedValueOnce( {
66+
json: () => Promise.resolve( { country_short: 'US' } ),
67+
} );
68+
const next = jest.fn();
69+
70+
const middleware = middlewareGeoipHeader();
71+
72+
await middleware( request, response, next );
73+
await middleware( request, response, next );
74+
await middleware( request, response, next );
75+
76+
expect( global.fetch ).toHaveBeenCalledTimes( 1 );
77+
} );
78+
79+
it( 'should not re-fetch on next request if previous fetch failed', async () => {
80+
( global.fetch as jest.Mock ).mockRejectedValue( new Error( 'Network error' ) );
81+
const next = jest.fn();
82+
83+
const middleware = middlewareGeoipHeader();
84+
85+
const req1 = { headers: {} } as Request;
86+
await middleware( req1, response, next );
87+
88+
expect( req1.headers[ 'x-geoip-country-code' ] ).toBeUndefined();
89+
expect( mockInfo ).toHaveBeenCalledWith( 'Failed to fetch geolocation' );
90+
91+
const req2 = { headers: {} } as Request;
92+
await middleware( req2, response, next );
93+
94+
expect( global.fetch ).toHaveBeenCalledTimes( 1 );
95+
expect( mockInfo ).toHaveBeenCalledTimes( 1 );
96+
expect( req2.headers[ 'x-geoip-country-code' ] ).toBeUndefined();
97+
} );
98+
} );

0 commit comments

Comments
 (0)