Skip to content

feat: add actual dev geolocation to functions and edge functions context #345

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c64e377
Initial plan
Copilot Jul 14, 2025
0ad614b
Add comprehensive geolocation functionality with API, caching, and mo…
Copilot Jul 14, 2025
8d22226
Address code review feedback: use LocalState class, remove CLI refere…
Copilot Jul 14, 2025
3538b1b
Address code review feedback: fix import extension, use proper MockFe…
Copilot Jul 15, 2025
a6f33a9
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 15, 2025
91390ac
Fix nondeterministic tests by using mock geolocation in test environment
Copilot Jul 15, 2025
8cbc3e9
Add geolocation.mode option to edge functions config and update tests
Copilot Jul 15, 2025
fb6e6a0
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 15, 2025
e523edc
chore: reformat
serhalp Jul 15, 2025
e2a850b
refactor: fix wrong test types
serhalp Jul 15, 2025
238f0fc
build(eslint): disable annoying rule in test
serhalp Jul 15, 2025
85711f2
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 15, 2025
4d6fd53
Move geolocation config to top level to apply to both functions and e…
Copilot Jul 15, 2025
b8460df
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 18, 2025
0c1a52b
Refactor geolocation API from mode to enabled/cache booleans
Copilot Jul 18, 2025
34e158b
Refactor geolocation config to follow existing pattern and simplify API
Copilot Jul 18, 2025
9cc5178
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 21, 2025
c19f481
Fix vite plugin integration tests for middleware features message
Copilot Jul 21, 2025
4561402
refactor: share geolocation instance across functions and edge functions
Copilot Jul 21, 2025
3fc487d
Remove geoCountry parameter and simplify geolocation API
Copilot Jul 22, 2025
0f679f9
Merge branch 'main' into copilot/fix-6bc5ea3f-a2cf-4ad8-910c-0072fd86…
serhalp Jul 22, 2025
2371001
feat: use Geolocation type for geolocation variable
Copilot Jul 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export default tseslint.config(
},
],
'n/no-unsupported-features/node-builtins': 'off',

// Disable unsafe assignment for test files due to vitest expect matchers returning `any`
// See: https://github.com/vitest-dev/vitest/issues/7015
'@typescript-eslint/no-unsafe-assignment': 'off',
},
},

Expand Down
181 changes: 181 additions & 0 deletions packages/dev-utils/src/lib/geo-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'
import type { MockedFunction } from 'vitest'

import { getGeoLocation, mockLocation } from './geo-location.js'
import { MockFetch } from '../test/fetch.js'

describe('geolocation', () => {
let mockState: {
get: MockedFunction<(key: string) => unknown>
set: MockedFunction<(key: string, value: unknown) => void>
}
let mockFetch: MockFetch

beforeEach(() => {
vi.clearAllMocks()
mockState = {
get: vi.fn(),
set: vi.fn(),
}
mockFetch = new MockFetch()
})

afterEach(() => {
mockFetch.restore()
})

describe('getGeoLocation', () => {
test('returns mock location when enabled is false', async () => {
const result = await getGeoLocation({
enabled: false,
state: mockState,
})

expect(result).toEqual(mockLocation)
expect(mockState.get).not.toHaveBeenCalled()
expect(mockState.set).not.toHaveBeenCalled()
expect(mockFetch.fulfilled).toBe(true)
})

test('returns cached data when cache is enabled and data is fresh', async () => {
const cachedData = {
city: 'Cached City',
country: { code: 'CA', name: 'Canada' },
subdivision: { code: 'ON', name: 'Ontario' },
longitude: -79.3832,
latitude: 43.6532,
timezone: 'America/Toronto',
}

mockState.get.mockReturnValue({
data: cachedData,
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago
})

const result = await getGeoLocation({
enabled: true,
cache: true,
state: mockState,
})

expect(result).toEqual(cachedData)
expect(mockState.get).toHaveBeenCalledWith('geolocation')
expect(mockFetch.fulfilled).toBe(true)
})

test('fetches new data when cache is enabled but data is stale', async () => {
const staleData = {
city: 'Stale City',
country: { code: 'CA', name: 'Canada' },
subdivision: { code: 'ON', name: 'Ontario' },
longitude: -79.3832,
latitude: 43.6532,
timezone: 'America/Toronto',
}

const freshData = {
city: 'Fresh City',
country: { code: 'US', name: 'United States' },
subdivision: { code: 'NY', name: 'New York' },
longitude: -74.006,
latitude: 40.7128,
timezone: 'America/New_York',
}

mockState.get.mockReturnValue({
data: staleData,
timestamp: Date.now() - 1000 * 60 * 60 * 25, // 25 hours ago (stale)
})

mockFetch
.get({
url: 'https://netlifind.netlify.app',
response: new Response(JSON.stringify({ geo: freshData }), {
headers: { 'Content-Type': 'application/json' },
}),
})
.inject()

const result = await getGeoLocation({
enabled: true,
cache: true,
state: mockState,
})

expect(result).toEqual(freshData)
expect(mockState.get).toHaveBeenCalledWith('geolocation')
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
data: freshData,
timestamp: expect.any(Number),
})
expect(mockFetch.fulfilled).toBe(true)
})

test('always fetches new data when cache is disabled', async () => {
const cachedData = {
city: 'Cached City',
country: { code: 'CA', name: 'Canada' },
subdivision: { code: 'ON', name: 'Ontario' },
longitude: -79.3832,
latitude: 43.6532,
timezone: 'America/Toronto',
}

const freshData = {
city: 'Fresh City',
country: { code: 'US', name: 'United States' },
subdivision: { code: 'NY', name: 'New York' },
longitude: -74.006,
latitude: 40.7128,
timezone: 'America/New_York',
}

mockState.get.mockReturnValue({
data: cachedData,
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago (fresh)
})

mockFetch
.get({
url: 'https://netlifind.netlify.app',
response: new Response(JSON.stringify({ geo: freshData }), {
headers: { 'Content-Type': 'application/json' },
}),
})
.inject()

const result = await getGeoLocation({
enabled: true,
cache: false,
state: mockState,
})

expect(result).toEqual(freshData)
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
data: freshData,
timestamp: expect.any(Number),
})
expect(mockFetch.fulfilled).toBe(true)
})

test('returns mock location when API request fails', async () => {
mockState.get.mockReturnValue(undefined)

mockFetch
.get({
url: 'https://netlifind.netlify.app',
response: new Error('Network error'),
})
.inject()

const result = await getGeoLocation({
enabled: true,
cache: false,
state: mockState,
})

expect(result).toEqual(mockLocation)
expect(mockFetch.fulfilled).toBe(true)
})
})
})
73 changes: 73 additions & 0 deletions packages/dev-utils/src/lib/geo-location.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Context } from '@netlify/types'

import type { LocalState } from './local-state.js'

export type Geolocation = Context['geo']

export const mockLocation: Geolocation = {
Expand All @@ -10,3 +12,74 @@ export const mockLocation: Geolocation = {
latitude: 0,
timezone: 'UTC',
}

const API_URL = 'https://netlifind.netlify.app'
const STATE_GEO_PROPERTY = 'geolocation'
// 24 hours
const CACHE_TTL = 8.64e7

// 10 seconds
const REQUEST_TIMEOUT = 1e4

/**
* Returns geolocation data from a remote API, the local cache, or a mock location, depending on the
* specified options.
*/
export const getGeoLocation = async ({
enabled = true,
cache = true,
state,
}: {
enabled?: boolean
cache?: boolean
state: LocalState
}): Promise<Geolocation> => {
// Early return for disabled mode
if (!enabled) {
return mockLocation
}

const cacheObject = state.get(STATE_GEO_PROPERTY) as { data: Geolocation; timestamp: number } | undefined

// If we have cached geolocation data and caching is enabled, let's try to use it.
if (cacheObject !== undefined && cache) {
const age = Date.now() - cacheObject.timestamp

// Let's use the cached data if it's not older than the TTL.
if (age < CACHE_TTL) {
return cacheObject.data
}
}

// Trying to retrieve geolocation data from the API and caching it locally.
try {
const data = await getGeoLocationFromAPI()

// Always cache the data for future use
const newCacheObject = {
data,
timestamp: Date.now(),
}

state.set(STATE_GEO_PROPERTY, newCacheObject)

return data
} catch {
// We couldn't get geolocation data from the API, so let's return the
// mock location.
return mockLocation
}
}

/**
* Returns geolocation data from a remote API.
*/
const getGeoLocationFromAPI = async (): Promise<Geolocation> => {
const res = await fetch(API_URL, {
method: 'GET',
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
})
const { geo } = (await res.json()) as { geo: Geolocation }

return geo
}
2 changes: 1 addition & 1 deletion packages/dev-utils/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export { getAPIToken } from './lib/api-token.js'
export { shouldBase64Encode } from './lib/base64.js'
export { renderFunctionErrorPage } from './lib/errors.js'
export { DevEvent, DevEventHandler } from './lib/event.js'
export { type Geolocation, mockLocation } from './lib/geo-location.js'
export { type Geolocation, mockLocation, getGeoLocation } from './lib/geo-location.js'
export { ensureNetlifyIgnore } from './lib/gitignore.js'
export { headers, toMultiValueHeaders } from './lib/headers.js'
export { getGlobalConfigStore, GlobalConfigStore, resetConfigCache } from './lib/global-config.js'
Expand Down
Loading
Loading