Skip to content

feat: add geolocation data to Netlify Dev #4566

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 9 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/commands/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ netlify dev
- `framework` (*string*) - framework to use. Defaults to #auto which automatically detects a framework
- `functions` (*string*) - specify a functions folder to serve
- `functionsPort` (*string*) - port of functions server
- `geo` (*cache | mock | update*) - force geolocation data to be updated, use cached data from the last 24h if found, or use a mock location
- `live` (*boolean*) - start a public live session
- `offline` (*boolean*) - disables any features that require network access
- `port` (*string*) - port of netlify dev
Expand Down
55 changes: 55 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"Sam Holmes <[email protected]> (https://samholmes.net)",
"Sander de Groot (https://degroot.dev)",
"Sarah Drasner <[email protected]> (https://twitter.com/sarah_edo)",
"Sarah Etter <[email protected]> (http://www.sarahetter.com)",
"Scott Spence <[email protected]> (https://twitter.com/spences10)",
"Sean Grove <[email protected]> (https://twitter.com/sgrove)",
"Sebastian Smolorz",
Expand Down Expand Up @@ -330,6 +331,7 @@
"husky": "^7.0.4",
"ini": "^2.0.0",
"mock-fs": "^5.1.2",
"nock": "^13.2.4",
"p-timeout": "^4.0.0",
"rewiremock": "^3.14.3",
"seedrandom": "^3.0.5",
Expand Down
42 changes: 34 additions & 8 deletions src/commands/dev/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,18 +201,33 @@ const FRAMEWORK_PORT_TIMEOUT = 6e5
* @param {*} params.addonsUrls
* @param {import('../base-command').NetlifyOptions["config"]} params.config
* @param {() => Promise<object>} params.getUpdatedConfig
* @param {string} params.geolocationMode
* @param {*} params.settings
* @param {boolean} params.offline
* @param {*} params.site
* @param {import('../../utils/state-config').StateConfig} params.state
* @returns
*/
const startProxyServer = async ({ addonsUrls, config, getUpdatedConfig, settings, site }) => {
const startProxyServer = async ({
addonsUrls,
config,
geolocationMode,
getUpdatedConfig,
offline,
settings,
site,
state,
}) => {
const url = await startProxy({
addonsUrls,
config,
configPath: site.configPath,
geolocationMode,
getUpdatedConfig,
offline,
projectDir: site.root,
settings,
state,
})

if (!url) {
Expand Down Expand Up @@ -365,7 +380,16 @@ const dev = async (options, command) => {
return normalizedNewConfig
}

let url = await startProxyServer({ settings, site, addonsUrls, config, getUpdatedConfig })
let url = await startProxyServer({
addonsUrls,
config,
geolocationMode: options.geo,
getUpdatedConfig,
offline: options.offline,
settings,
site,
state,
})

const liveTunnelUrl = await handleLiveTunnel({ options, site, api, settings })
url = liveTunnelUrl || url
Expand Down Expand Up @@ -484,17 +508,19 @@ const createDevCommand = (program) => {
.option('-o ,--offline', 'disables any features that require network access')
.option('-l, --live', 'start a public live session', false)
.option('--functionsPort <port>', 'port of functions server', (value) => Number.parseInt(value))
.addOption(
new Option(
'--geo <mode>',
'force geolocation data to be updated, use cached data from the last 24h if found, or use a mock location',
)
.choices(['cache', 'mock', 'update'])
.default('cache'),
)
.addOption(
new Option('--staticServerPort <port>', 'port of the static app server used when no framework is detected')
.argParser((value) => Number.parseInt(value))
.hideHelp(),
)
.addOption(
new Option(
'-g ,--locationDb <path>',
'specify the path to a local GeoIP location database in MMDB format',
).hideHelp(),
)
.addOption(new Option('--graph', 'enable Netlify Graph support').hideHelp())
.addExamples([
'netlify dev',
Expand Down
1 change: 1 addition & 0 deletions src/lib/edge-functions/headers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
Functions: 'x-deno-functions',
Geo: 'x-nf-geo',
PassHost: 'X-NF-Pass-Host',
Passthrough: 'x-deno-pass',
RequestID: 'X-NF-Request-ID',
Expand Down
11 changes: 9 additions & 2 deletions src/lib/edge-functions/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const getAvailablePort = require('get-port')
const { v4: generateUUID } = require('uuid')

const { NETLIFYDEVERR, NETLIFYDEVWARN, chalk, log } = require('../../utils/command-helpers')
const { getGeoLocation } = require('../geo-location')
const { getPathInProject } = require('../settings')
const { startSpinner, stopSpinner } = require('../spinner')

Expand Down Expand Up @@ -41,7 +42,7 @@ const handleProxyRequest = (req, proxyReq) => {
})
}

const initializeProxy = async ({ config, configPath, getUpdatedConfig, settings }) => {
const initializeProxy = async ({ config, configPath, geolocationMode, getUpdatedConfig, offline, settings, state }) => {
const { functions: internalFunctions, importMap, path: internalFunctionsPath } = await getInternalFunctions()
const { port: mainPort } = settings
const userFunctionsPath = config.build.edge_functions
Expand All @@ -66,7 +67,13 @@ const initializeProxy = async ({ config, configPath, getUpdatedConfig, settings
return
}

const { registry } = await server
const [geoLocation, { registry }] = await Promise.all([
getGeoLocation({ mode: geolocationMode, offline, state }),
server,
])

// Setting header with geolocation.
req.headers[headers.Geo] = JSON.stringify(geoLocation)

await registry.initialize()

Expand Down
99 changes: 99 additions & 0 deletions src/lib/geo-location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @ts-check
const fetch = require('node-fetch')

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

/**
* @typedef GeoLocation
* @type {object}
* @property {string} city
* @property {object} country
* @property {string} country.code
* @property {string} country.name
* @property {object} country
* @property {string} country.code
* @property {string} country.name
*/

// The default location to be used if we're unable to talk to the API.
const mockLocation = {
city: 'San Francisco',
country: { code: 'US', name: 'United States' },
subdivision: { code: 'CA', name: 'California' },
}

/**
* Returns geolocation data from a remote API, the local cache, or a mock
* location, depending on the mode selected.
*
* @param {object} params
* @param {string} params.geolocationMode
* @param {"cache"|"update"|"mock"} params.mode
* @param {boolean} params.offline
* @param {import('../utils/state-config').StateConfig} params.state
* @returns {Promise<GeoLocation>}
*/
const getGeoLocation = async ({ mode, offline, state }) => {
const cacheObject = state.get(STATE_GEO_PROPERTY)

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

// Let's use the cached data if it's not older than the TTL. Also, if the
// `--offline` option was used, it's best to use the cached location than
// the mock one.
if (age < CACHE_TTL || offline) {
return cacheObject.data
}
}

// If the `--geo` option is set to `mock`, we use the mock location. Also,
// if the `--offline` option was used, we can't talk to the API, so let's
// also use the mock location.
if (mode === 'mock' || offline) {
return mockLocation
}

// Trying to retrieve geolocation data from the API and caching it locally.
try {
const data = await getGeoLocationFromAPI()
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
*
* @returns {Promise<GeoLocation>}
*/
const getGeoLocationFromAPI = async () => {
const res = await fetch(API_URL, {
method: 'GET',
timeout: REQUEST_TIMEOUT,
})
const { geo } = await res.json()

return geo
}

module.exports = { getGeoLocation, mockLocation }
15 changes: 14 additions & 1 deletion src/utils/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,26 @@ const onRequest = async ({ addonsUrls, edgeFunctionsProxy, functionsServer, prox
proxy.web(req, res, options)
}

const startProxy = async function ({ addonsUrls, config, configPath, getUpdatedConfig, projectDir, settings }) {
const startProxy = async function ({
addonsUrls,
config,
configPath,
geolocationMode,
getUpdatedConfig,
offline,
projectDir,
settings,
state,
}) {
const functionsServer = settings.functionsPort ? `http://localhost:${settings.functionsPort}` : null
const edgeFunctionsProxy = await edgeFunctions.initializeProxy({
config,
configPath,
geolocationMode,
getUpdatedConfig,
offline,
settings,
state,
})
const proxy = await initializeProxy({
port: settings.frameworkPort,
Expand Down
Loading