Skip to content

Commit 916c38b

Browse files
Copilotserhalp
andauthored
feat: add actual dev geolocation to functions and edge functions context (#345)
* Initial plan * Add comprehensive geolocation functionality with API, caching, and mode support Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Address code review feedback: use LocalState class, remove CLI references, hook up to edge functions Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Address code review feedback: fix import extension, use proper MockFetch pattern, fix type errors Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Fix nondeterministic tests by using mock geolocation in test environment Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Add geolocation.mode option to edge functions config and update tests Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * chore: reformat * refactor: fix wrong test types * build(eslint): disable annoying rule in test * Move geolocation config to top level to apply to both functions and edge functions Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Refactor geolocation API from mode to enabled/cache booleans Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Refactor geolocation config to follow existing pattern and simplify API Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Fix vite plugin integration tests for middleware features message Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * refactor: share geolocation instance across functions and edge functions Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * Remove geoCountry parameter and simplify geolocation API Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> * feat: use Geolocation type for geolocation variable Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com> Co-authored-by: Philippe Serhal <philippe.serhal@netlify.com>
1 parent ead53c7 commit 916c38b

File tree

7 files changed

+370
-5
lines changed

7 files changed

+370
-5
lines changed

eslint.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ export default tseslint.config(
119119
},
120120
],
121121
'n/no-unsupported-features/node-builtins': 'off',
122+
123+
// Disable unsafe assignment for test files due to vitest expect matchers returning `any`
124+
// See: https://github.com/vitest-dev/vitest/issues/7015
125+
'@typescript-eslint/no-unsafe-assignment': 'off',
122126
},
123127
},
124128

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'
2+
import type { MockedFunction } from 'vitest'
3+
4+
import { getGeoLocation, mockLocation } from './geo-location.js'
5+
import { MockFetch } from '../test/fetch.js'
6+
7+
describe('geolocation', () => {
8+
let mockState: {
9+
get: MockedFunction<(key: string) => unknown>
10+
set: MockedFunction<(key: string, value: unknown) => void>
11+
}
12+
let mockFetch: MockFetch
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks()
16+
mockState = {
17+
get: vi.fn(),
18+
set: vi.fn(),
19+
}
20+
mockFetch = new MockFetch()
21+
})
22+
23+
afterEach(() => {
24+
mockFetch.restore()
25+
})
26+
27+
describe('getGeoLocation', () => {
28+
test('returns mock location when enabled is false', async () => {
29+
const result = await getGeoLocation({
30+
enabled: false,
31+
state: mockState,
32+
})
33+
34+
expect(result).toEqual(mockLocation)
35+
expect(mockState.get).not.toHaveBeenCalled()
36+
expect(mockState.set).not.toHaveBeenCalled()
37+
expect(mockFetch.fulfilled).toBe(true)
38+
})
39+
40+
test('returns cached data when cache is enabled and data is fresh', async () => {
41+
const cachedData = {
42+
city: 'Cached City',
43+
country: { code: 'CA', name: 'Canada' },
44+
subdivision: { code: 'ON', name: 'Ontario' },
45+
longitude: -79.3832,
46+
latitude: 43.6532,
47+
timezone: 'America/Toronto',
48+
}
49+
50+
mockState.get.mockReturnValue({
51+
data: cachedData,
52+
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago
53+
})
54+
55+
const result = await getGeoLocation({
56+
enabled: true,
57+
cache: true,
58+
state: mockState,
59+
})
60+
61+
expect(result).toEqual(cachedData)
62+
expect(mockState.get).toHaveBeenCalledWith('geolocation')
63+
expect(mockFetch.fulfilled).toBe(true)
64+
})
65+
66+
test('fetches new data when cache is enabled but data is stale', async () => {
67+
const staleData = {
68+
city: 'Stale City',
69+
country: { code: 'CA', name: 'Canada' },
70+
subdivision: { code: 'ON', name: 'Ontario' },
71+
longitude: -79.3832,
72+
latitude: 43.6532,
73+
timezone: 'America/Toronto',
74+
}
75+
76+
const freshData = {
77+
city: 'Fresh City',
78+
country: { code: 'US', name: 'United States' },
79+
subdivision: { code: 'NY', name: 'New York' },
80+
longitude: -74.006,
81+
latitude: 40.7128,
82+
timezone: 'America/New_York',
83+
}
84+
85+
mockState.get.mockReturnValue({
86+
data: staleData,
87+
timestamp: Date.now() - 1000 * 60 * 60 * 25, // 25 hours ago (stale)
88+
})
89+
90+
mockFetch
91+
.get({
92+
url: 'https://netlifind.netlify.app',
93+
response: new Response(JSON.stringify({ geo: freshData }), {
94+
headers: { 'Content-Type': 'application/json' },
95+
}),
96+
})
97+
.inject()
98+
99+
const result = await getGeoLocation({
100+
enabled: true,
101+
cache: true,
102+
state: mockState,
103+
})
104+
105+
expect(result).toEqual(freshData)
106+
expect(mockState.get).toHaveBeenCalledWith('geolocation')
107+
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
108+
data: freshData,
109+
timestamp: expect.any(Number),
110+
})
111+
expect(mockFetch.fulfilled).toBe(true)
112+
})
113+
114+
test('always fetches new data when cache is disabled', async () => {
115+
const cachedData = {
116+
city: 'Cached City',
117+
country: { code: 'CA', name: 'Canada' },
118+
subdivision: { code: 'ON', name: 'Ontario' },
119+
longitude: -79.3832,
120+
latitude: 43.6532,
121+
timezone: 'America/Toronto',
122+
}
123+
124+
const freshData = {
125+
city: 'Fresh City',
126+
country: { code: 'US', name: 'United States' },
127+
subdivision: { code: 'NY', name: 'New York' },
128+
longitude: -74.006,
129+
latitude: 40.7128,
130+
timezone: 'America/New_York',
131+
}
132+
133+
mockState.get.mockReturnValue({
134+
data: cachedData,
135+
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago (fresh)
136+
})
137+
138+
mockFetch
139+
.get({
140+
url: 'https://netlifind.netlify.app',
141+
response: new Response(JSON.stringify({ geo: freshData }), {
142+
headers: { 'Content-Type': 'application/json' },
143+
}),
144+
})
145+
.inject()
146+
147+
const result = await getGeoLocation({
148+
enabled: true,
149+
cache: false,
150+
state: mockState,
151+
})
152+
153+
expect(result).toEqual(freshData)
154+
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
155+
data: freshData,
156+
timestamp: expect.any(Number),
157+
})
158+
expect(mockFetch.fulfilled).toBe(true)
159+
})
160+
161+
test('returns mock location when API request fails', async () => {
162+
mockState.get.mockReturnValue(undefined)
163+
164+
mockFetch
165+
.get({
166+
url: 'https://netlifind.netlify.app',
167+
response: new Error('Network error'),
168+
})
169+
.inject()
170+
171+
const result = await getGeoLocation({
172+
enabled: true,
173+
cache: false,
174+
state: mockState,
175+
})
176+
177+
expect(result).toEqual(mockLocation)
178+
expect(mockFetch.fulfilled).toBe(true)
179+
})
180+
})
181+
})

packages/dev-utils/src/lib/geo-location.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Context } from '@netlify/types'
22

3+
import type { LocalState } from './local-state.js'
4+
35
export type Geolocation = Context['geo']
46

57
export const mockLocation: Geolocation = {
@@ -10,3 +12,74 @@ export const mockLocation: Geolocation = {
1012
latitude: 0,
1113
timezone: 'UTC',
1214
}
15+
16+
const API_URL = 'https://netlifind.netlify.app'
17+
const STATE_GEO_PROPERTY = 'geolocation'
18+
// 24 hours
19+
const CACHE_TTL = 8.64e7
20+
21+
// 10 seconds
22+
const REQUEST_TIMEOUT = 1e4
23+
24+
/**
25+
* Returns geolocation data from a remote API, the local cache, or a mock location, depending on the
26+
* specified options.
27+
*/
28+
export const getGeoLocation = async ({
29+
enabled = true,
30+
cache = true,
31+
state,
32+
}: {
33+
enabled?: boolean
34+
cache?: boolean
35+
state: LocalState
36+
}): Promise<Geolocation> => {
37+
// Early return for disabled mode
38+
if (!enabled) {
39+
return mockLocation
40+
}
41+
42+
const cacheObject = state.get(STATE_GEO_PROPERTY) as { data: Geolocation; timestamp: number } | undefined
43+
44+
// If we have cached geolocation data and caching is enabled, let's try to use it.
45+
if (cacheObject !== undefined && cache) {
46+
const age = Date.now() - cacheObject.timestamp
47+
48+
// Let's use the cached data if it's not older than the TTL.
49+
if (age < CACHE_TTL) {
50+
return cacheObject.data
51+
}
52+
}
53+
54+
// Trying to retrieve geolocation data from the API and caching it locally.
55+
try {
56+
const data = await getGeoLocationFromAPI()
57+
58+
// Always cache the data for future use
59+
const newCacheObject = {
60+
data,
61+
timestamp: Date.now(),
62+
}
63+
64+
state.set(STATE_GEO_PROPERTY, newCacheObject)
65+
66+
return data
67+
} catch {
68+
// We couldn't get geolocation data from the API, so let's return the
69+
// mock location.
70+
return mockLocation
71+
}
72+
}
73+
74+
/**
75+
* Returns geolocation data from a remote API.
76+
*/
77+
const getGeoLocationFromAPI = async (): Promise<Geolocation> => {
78+
const res = await fetch(API_URL, {
79+
method: 'GET',
80+
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
81+
})
82+
const { geo } = (await res.json()) as { geo: Geolocation }
83+
84+
return geo
85+
}

packages/dev-utils/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export { getAPIToken } from './lib/api-token.js'
22
export { shouldBase64Encode } from './lib/base64.js'
33
export { renderFunctionErrorPage } from './lib/errors.js'
44
export { DevEvent, DevEventHandler } from './lib/event.js'
5-
export { type Geolocation, mockLocation } from './lib/geo-location.js'
5+
export { type Geolocation, mockLocation, getGeoLocation } from './lib/geo-location.js'
66
export { ensureNetlifyIgnore } from './lib/gitignore.js'
77
export { headers, toMultiValueHeaders } from './lib/headers.js'
88
export { getGlobalConfigStore, GlobalConfigStore, resetConfigCache } from './lib/global-config.js'

0 commit comments

Comments
 (0)