Skip to content

Commit

Permalink
feat: add geolocation data to Netlify Dev (#4566)
Browse files Browse the repository at this point in the history
* feat: add support for geolocation

* chore: add tests

* chore: update contributors field

* chore: update docs

* chore: fix tests

* chore: update tests

* chore: skip test

* chore: add type annotations

Co-authored-by: eduardoboucas <eduardoboucas@users.noreply.github.com>
  • Loading branch information
eduardoboucas and eduardoboucas authored Apr 27, 2022
1 parent 2c58896 commit f35b14c
Show file tree
Hide file tree
Showing 14 changed files with 411 additions and 54 deletions.
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 <samholmes1337@gmail.com> (https://samholmes.net)",
"Sander de Groot (https://degroot.dev)",
"Sarah Drasner <sarah.drasner@gmail.com> (https://twitter.com/sarah_edo)",
"Sarah Etter <sarah@sarahetter.com> (http://www.sarahetter.com)",
"Scott Spence <spences10apps@gmail.com> (https://twitter.com/spences10)",
"Sean Grove <sean@bushi.do> (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

1 comment on commit f35b14c

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

Package size: 273 MB

Please sign in to comment.