Skip to content

feat: add actual dev geolocation to 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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
226 changes: 226 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,226 @@
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 custom mock location when geoCountry is provided', async () => {
const result = await getGeoLocation({
enabled: true,
cache: true,
geoCountry: 'FR',
state: mockState,
})

expect(result).toEqual({
city: 'Mock City',
country: { code: 'FR', name: 'Mock Country' },
subdivision: { code: 'SD', name: 'Mock Subdivision' },
longitude: 0,
latitude: 0,
timezone: 'UTC',
})
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)
})

test('uses cached data when country matches geoCountry', async () => {
const cachedData = {
city: 'Paris',
country: { code: 'FR', name: 'France' },
subdivision: { code: 'IDF', name: 'Île-de-France' },
longitude: 2.3522,
latitude: 48.8566,
timezone: 'Europe/Paris',
}

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

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

expect(result).toEqual(cachedData)
expect(mockFetch.fulfilled).toBe(true)
})
})
})
94 changes: 94 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,95 @@ 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 ({
geoCountry,
enabled = true,
cache = true,
state,
}: {
enabled?: boolean
cache?: boolean
geoCountry?: string | undefined
state: LocalState
}): Promise<Geolocation> => {
// Early return for disabled mode (no geoCountry)
if (!enabled && !geoCountry) {
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.
// Or, if the country we're trying to mock is the same one as we have in the
// cache, let's use the cache instead of the mock.
if (cacheObject !== undefined && (cache || cacheObject.data.country?.code === geoCountry)) {
const age = Date.now() - cacheObject.timestamp

// Let's use the cached data if it's not older than the TTL.
// Additionally, if we're trying to mock a country that matches the cached country,
// prefer the cached data over the mock.
if (age < CACHE_TTL || cacheObject.data.country?.code === geoCountry) {
return cacheObject.data
}
}

// If a country code was provided or geolocation is disabled, we use mock location data.
if (geoCountry || !enabled) {
if (geoCountry) {
return {
city: 'Mock City',
country: { code: geoCountry, name: 'Mock Country' },
subdivision: { code: 'SD', name: 'Mock Subdivision' },
longitude: 0,
latitude: 0,
timezone: 'UTC',
}
}
return mockLocation
}

// 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