diff --git a/eslint.config.js b/eslint.config.js index 4dcae2f7a..03d964990 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', }, }, diff --git a/packages/dev-utils/src/lib/geo-location.test.ts b/packages/dev-utils/src/lib/geo-location.test.ts new file mode 100644 index 000000000..e768c8bfb --- /dev/null +++ b/packages/dev-utils/src/lib/geo-location.test.ts @@ -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) + }) + }) +}) diff --git a/packages/dev-utils/src/lib/geo-location.ts b/packages/dev-utils/src/lib/geo-location.ts index e0a2adce7..dd6f5033d 100644 --- a/packages/dev-utils/src/lib/geo-location.ts +++ b/packages/dev-utils/src/lib/geo-location.ts @@ -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 = { @@ -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 => { + // 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 => { + const res = await fetch(API_URL, { + method: 'GET', + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }) + const { geo } = (await res.json()) as { geo: Geolocation } + + return geo +} diff --git a/packages/dev-utils/src/main.ts b/packages/dev-utils/src/main.ts index e62b49c09..3eafd968e 100644 --- a/packages/dev-utils/src/main.ts +++ b/packages/dev-utils/src/main.ts @@ -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' diff --git a/packages/dev/src/main.test.ts b/packages/dev/src/main.test.ts index 4b821c5d1..2d0129d51 100644 --- a/packages/dev/src/main.test.ts +++ b/packages/dev/src/main.test.ts @@ -29,6 +29,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/from') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -57,6 +61,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/from') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -85,6 +93,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/from') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -120,6 +132,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello.txt') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -165,6 +181,10 @@ describe('Handling requests', () => { const directory = await fixture.create() const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -205,6 +225,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/shadowed-path.html') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -241,6 +265,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello.html') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -272,6 +300,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello?param1=value1') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -312,6 +344,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -342,6 +378,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/from') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -398,6 +438,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -440,6 +484,10 @@ describe('Handling requests', () => { const req = new Request('https://site.netlify/hello') const dev = new NetlifyDev({ projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -621,6 +669,10 @@ describe('Handling requests', () => { const dev = new NetlifyDev({ apiToken: 'token', projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) const { serverAddress } = await dev.start() @@ -762,6 +814,10 @@ describe('Handling requests', () => { apiURL: context.apiUrl, apiToken: 'token', projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() @@ -877,6 +933,10 @@ describe('Handling requests', () => { apiURL: context.apiUrl, apiToken: 'token', projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) const { serverAddress } = await dev.start() @@ -976,6 +1036,10 @@ describe('Handling requests', () => { apiURL: context.apiUrl, apiToken: 'token', projectRoot: directory, + edgeFunctions: {}, + geolocation: { + enabled: false, + }, }) await dev.start() diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index 2f1a71eab..64d95c756 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -4,7 +4,15 @@ import path from 'node:path' import process from 'node:process' import { resolveConfig } from '@netlify/config' -import { ensureNetlifyIgnore, getAPIToken, mockLocation, LocalState, type Logger, HTTPServer } from '@netlify/dev-utils' +import { + ensureNetlifyIgnore, + getAPIToken, + getGeoLocation, + type Geolocation, + LocalState, + type Logger, + HTTPServer, +} from '@netlify/dev-utils' import { EdgeFunctionsHandler } from '@netlify/edge-functions/dev' import { FunctionsHandler } from '@netlify/functions/dev' import { HeadersHandler, type HeadersCollector } from '@netlify/headers' @@ -55,6 +63,23 @@ export interface Features { enabled?: boolean } + /** + * Configuration options for geolocation data used by Functions and Edge Functions. + * + * {@link} https://docs.netlify.com/build/edge-functions/api/#geo + */ + geolocation?: { + enabled?: boolean + + /** + * Cache the result of the API call. When disabled, the location is retrieved + * each time. + * + * {@default} true + */ + cache?: boolean + } + /** * Configuration options for Netlify response headers. * @@ -148,6 +173,7 @@ export class NetlifyDev { #cleanupJobs: (() => Promise)[] #edgeFunctionsHandler?: EdgeFunctionsHandler #functionsHandler?: FunctionsHandler + #geolocationConfig?: NetlifyDevOptions['geolocation'] #functionsServePath: string #config?: Config #features: { @@ -155,6 +181,7 @@ export class NetlifyDev { edgeFunctions: boolean environmentVariables: boolean functions: boolean + geolocation: boolean headers: boolean images: boolean redirects: boolean @@ -183,11 +210,13 @@ export class NetlifyDev { this.#apiToken = options.apiToken this.#cleanupJobs = [] + this.#geolocationConfig = options.geolocation this.#features = { blobs: options.blobs?.enabled !== false, edgeFunctions: options.edgeFunctions?.enabled !== false, environmentVariables: options.environmentVariables?.enabled !== false, functions: options.functions?.enabled !== false, + geolocation: options.geolocation?.enabled !== false, headers: options.headers?.enabled !== false, images: options.images?.enabled !== false, redirects: options.redirects?.enabled !== false, @@ -463,6 +492,8 @@ export class NetlifyDev { }) } + let geolocation: Geolocation | undefined + if (this.#features.edgeFunctions) { const edgeFunctionsEnv = { // User-defined env vars + documented runtime env vars @@ -486,11 +517,17 @@ export class NetlifyDev { ), } + geolocation ??= await getGeoLocation({ + enabled: this.#features.geolocation, + cache: this.#geolocationConfig?.cache ?? true, + state, + }) + const edgeFunctionsHandler = new EdgeFunctionsHandler({ configDeclarations: this.#config?.config.edge_functions ?? [], directories: [this.#config?.config.build.edge_functions].filter(Boolean) as string[], env: edgeFunctionsEnv, - geolocation: mockLocation, + geolocation, logger: this.#logger, siteID, siteName: config?.siteInfo.name, @@ -505,10 +542,16 @@ export class NetlifyDev { this.#config?.config.functionsDirectory ?? path.join(this.#projectRoot, 'netlify/functions') const userFunctionsPathExists = await isDirectory(userFunctionsPath) + geolocation ??= await getGeoLocation({ + enabled: this.#features.geolocation, + cache: this.#geolocationConfig?.cache ?? true, + state, + }) + this.#functionsHandler = new FunctionsHandler({ config: this.#config, destPath: this.#functionsServePath, - geolocation: mockLocation, + geolocation, projectRoot: this.#projectRoot, settings: {}, siteId: this.#siteID, diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 601c5a0e0..1fc481a3c 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -208,7 +208,7 @@ defined on your team and site and much more. Run npx netlify init to get started expect(mockLogger.info).toHaveBeenNthCalledWith(1, 'Environment loaded', expect.objectContaining({})) expect(mockLogger.info).toHaveBeenNthCalledWith( 2, - 'Middleware loaded. Emulating features: blobs, environmentVariables, functions, headers, images, redirects, static.', + 'Middleware loaded. Emulating features: blobs, environmentVariables, functions, geolocation, headers, images, redirects, static.', expect.objectContaining({}), ) expect(mockLogger.info).toHaveBeenNthCalledWith(