From 25497518ab0ce5f327db87df251f33c233cf62a5 Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Thu, 12 Nov 2020 17:21:16 +0200 Subject: [PATCH] fix(command-dev): warn if background functions are not supported (#1554) --- src/commands/dev/exec.js | 3 +- src/commands/dev/index.js | 15 ++++- src/commands/functions/create.js | 3 +- src/lib/account.js | 18 +++++ src/lib/build.js | 3 +- src/utils/command.js | 4 +- src/utils/dev.js | 64 ++++++++++-------- src/utils/serve-functions.js | 110 ++++++++++++++++++------------- tests/command.deploy.test.js | 10 +-- tests/command.dev.test.js | 4 +- 10 files changed, 147 insertions(+), 87 deletions(-) create mode 100644 src/lib/account.js diff --git a/src/commands/dev/exec.js b/src/commands/dev/exec.js index 1aa58de2172..09911a042f8 100644 --- a/src/commands/dev/exec.js +++ b/src/commands/dev/exec.js @@ -6,12 +6,13 @@ const { getSiteInformation, addEnvVariables } = require('../../utils/dev') class ExecCommand extends Command { async run() { const { log, warn, error, netlify } = this - const { site, api } = netlify + const { site, api, siteInfo } = netlify const { teamEnv, addonsEnv, siteEnv, dotFilesEnv } = await getSiteInformation({ api, site, warn, error, + siteInfo, }) await addEnvVariables({ log, teamEnv, addonsEnv, siteEnv, dotFilesEnv }) diff --git a/src/commands/dev/index.js b/src/commands/dev/index.js index 652941848fa..a0b061453be 100644 --- a/src/commands/dev/index.js +++ b/src/commands/dev/index.js @@ -169,7 +169,7 @@ class DevCommand extends Command { this.log(`${NETLIFYDEV}`) const { error: errorExit, log, warn, exit } = this const { flags } = this.parse(DevCommand) - const { api, site, config } = this.netlify + const { api, site, config, siteInfo } = this.netlify config.dev = { ...config.dev } config.build = { ...config.build } const devConfig = { @@ -180,12 +180,13 @@ class DevCommand extends Command { ...flags, } - const { addonsUrls, teamEnv, addonsEnv, siteEnv, dotFilesEnv } = await getSiteInformation({ + const { addonsUrls, teamEnv, addonsEnv, siteEnv, dotFilesEnv, siteUrl, capabilities } = await getSiteInformation({ flags, api, site, warn, error: errorExit, + siteInfo, }) await addEnvVariables({ log, teamEnv, addonsEnv, siteEnv, dotFilesEnv }) @@ -198,7 +199,15 @@ class DevCommand extends Command { exit(1) } - await startFunctionsServer({ settings, site, log, warn, errorExit, siteInfo: this.netlify.cachedConfig.siteInfo }) + await startFunctionsServer({ + settings, + site, + log, + warn, + errorExit, + siteUrl, + capabilities, + }) await startFrameworkServer({ settings, log, exit }) let url = await startProxyServer({ flags, settings, site, log, exit, addonsUrls }) diff --git a/src/commands/functions/create.js b/src/commands/functions/create.js index c1d54ebe629..7d1afa7b239 100644 --- a/src/commands/functions/create.js +++ b/src/commands/functions/create.js @@ -369,12 +369,13 @@ const createFunctionAddon = async function ({ api, addons, siteId, addonName, si const injectEnvVariables = async ({ context }) => { const { log, warn, error, netlify } = context - const { api, site } = netlify + const { api, site, siteInfo } = netlify const { teamEnv, addonsEnv, siteEnv, dotFilesEnv } = await getSiteInformation({ api, site, warn, error, + siteInfo, }) await addEnvVariables({ log, teamEnv, addonsEnv, siteEnv, dotFilesEnv }) } diff --git a/src/lib/account.js b/src/lib/account.js new file mode 100644 index 00000000000..276ea0a4db8 --- /dev/null +++ b/src/lib/account.js @@ -0,0 +1,18 @@ +const dotProp = require('dot-prop') + +const supportsBooleanCapability = (account, capability) => { + return dotProp.get(account, `capabilities.${capability}.included`) +} + +const supportsEdgeHandlers = (account) => { + return supportsBooleanCapability(account, 'edge_handlers') +} + +const supportsBackgroundFunctions = (account) => { + return supportsBooleanCapability(account, 'background_functions') +} + +module.exports = { + supportsBackgroundFunctions, + supportsEdgeHandlers, +} diff --git a/src/lib/build.js b/src/lib/build.js index 95e8f9102cc..efd46533275 100644 --- a/src/lib/build.js +++ b/src/lib/build.js @@ -4,12 +4,13 @@ const { getSiteInformation } = require('../utils/dev') const getBuildEnv = async ({ context }) => { const { warn, error, netlify } = context - const { site, api } = netlify + const { site, api, siteInfo } = netlify const { teamEnv, addonsEnv, siteEnv } = await getSiteInformation({ api, site, warn, error, + siteInfo, }) const env = { ...teamEnv, ...addonsEnv, ...siteEnv } return env diff --git a/src/utils/command.js b/src/utils/command.js index 77c7ba22901..cab0b22c9e0 100644 --- a/src/utils/command.js +++ b/src/utils/command.js @@ -54,7 +54,7 @@ class BaseCommand extends Command { const state = new StateConfig(cwd) const cachedConfig = await this.getConfig(cwd, state, token) - const { configPath, config, buildDir } = cachedConfig + const { configPath, config, buildDir, siteInfo } = cachedConfig const { flags } = this.parse(BaseCommand) const agent = await getAgent({ @@ -85,6 +85,8 @@ class BaseCommand extends Command { state.set('siteId', id) }, }, + // Site information retrieved using the API + siteInfo, // Configuration from netlify.[toml/yml] config, // Used to avoid calling @neltify/config again diff --git a/src/utils/dev.js b/src/utils/dev.js index 9d13b5d4cde..195c58b97da 100644 --- a/src/utils/dev.js +++ b/src/utils/dev.js @@ -2,6 +2,9 @@ const process = require('process') const fromEntries = require('@ungap/from-entries') const chalk = require('chalk') +const isEmpty = require('lodash.isempty') + +const { supportsBackgroundFunctions } = require('../lib/account') const { loadDotEnvFiles } = require('./dot-env') const { NETLIFYDEVLOG } = require('./logo') @@ -9,21 +12,16 @@ const { NETLIFYDEVLOG } = require('./logo') const ERROR_CALL_TO_ACTION = "Double-check your login status with 'netlify status' or contact support with details of your error." -const getSiteData = async ({ api, site, failAndExit }) => { - try { - const siteData = await api.getSite({ siteId: site.id }) - return siteData - } catch (error) { - failAndExit( - `Failed retrieving site data for site ${chalk.yellow(site.id)}: ${error.message}. ${ERROR_CALL_TO_ACTION}`, - ) +const validateSiteInfo = ({ site, siteInfo, failAndExit }) => { + if (isEmpty(siteInfo)) { + failAndExit(`Failed retrieving site information for site ${chalk.yellow(site.id)}. ${ERROR_CALL_TO_ACTION}`) } } const getAccounts = async ({ api, failAndExit }) => { try { - const account = await api.listAccountsForUser() - return account + const accounts = await api.listAccountsForUser() + return accounts } catch (error) { failAndExit(`Failed retrieving user account: ${error.message}. ${ERROR_CALL_TO_ACTION}`) } @@ -38,50 +36,64 @@ const getAddons = async ({ api, site, failAndExit }) => { } } -const getAddonsInformation = ({ siteData, addons }) => { - const urls = fromEntries(addons.map((addon) => [addon.service_slug, `${siteData.ssl_url}${addon.service_path}`])) +const getAddonsInformation = ({ siteInfo, addons }) => { + const urls = fromEntries(addons.map((addon) => [addon.service_slug, `${siteInfo.ssl_url}${addon.service_path}`])) const env = Object.assign({}, ...addons.map((addon) => addon.env)) return { urls, env } } -const getTeamEnv = ({ siteData, accounts }) => { - const siteAccount = accounts.find((account) => account.slug === siteData.account_slug) - if (siteAccount && siteAccount.site_env) { - return siteAccount.site_env +const getSiteAccount = ({ siteInfo, accounts, warn }) => { + const siteAccount = accounts.find((account) => account.slug === siteInfo.account_slug) + if (!siteAccount) { + warn(`Could not find account for site '${siteInfo.name}' with account slug '${siteInfo.account_slug}'`) + return {} + } + return siteAccount +} + +const getTeamEnv = ({ account }) => { + if (account.site_env) { + return account.site_env } return {} } -const getSiteEnv = ({ siteData }) => { - if (siteData.build_settings && siteData.build_settings.env) { - return siteData.build_settings.env +const getSiteEnv = ({ siteInfo }) => { + if (siteInfo.build_settings && siteInfo.build_settings.env) { + return siteInfo.build_settings.env } return {} } -const getSiteInformation = async ({ flags = {}, api, site, warn, error: failAndExit }) => { +const getSiteInformation = async ({ flags = {}, api, site, warn, error: failAndExit, siteInfo }) => { if (site.id && !flags.offline) { - const [siteData, accounts, addons, dotFilesEnv] = await Promise.all([ - getSiteData({ api, site, failAndExit }), + validateSiteInfo({ site, siteInfo, failAndExit }) + const [accounts, addons, dotFilesEnv] = await Promise.all([ getAccounts({ api, failAndExit }), getAddons({ api, site, failAndExit }), loadDotEnvFiles({ projectDir: site.root, warn }), ]) - const { urls: addonsUrls, env: addonsEnv } = getAddonsInformation({ siteData, addons }) - const teamEnv = getTeamEnv({ siteData, accounts }) - const siteEnv = getSiteEnv({ siteData }) + const { urls: addonsUrls, env: addonsEnv } = getAddonsInformation({ siteInfo, addons }) + const account = getSiteAccount({ siteInfo, accounts, warn }) + const teamEnv = getTeamEnv({ account }) + const siteEnv = getSiteEnv({ siteInfo }) + return { addonsUrls, teamEnv, addonsEnv, siteEnv, dotFilesEnv, + siteUrl: siteInfo.ssl_url, + capabilities: { + backgroundFunctions: supportsBackgroundFunctions(account), + }, } } const dotFilesEnv = await loadDotEnvFiles({ projectDir: site.root, warn }) - return { addonsUrls: {}, teamEnv: {}, addonsEnv: {}, siteEnv: {}, dotFilesEnv } + return { addonsUrls: {}, teamEnv: {}, addonsEnv: {}, siteEnv: {}, dotFilesEnv, siteUrl: '', capabilities: {} } } // if first arg is undefined, use default, but tell user about it in case it is unintentional diff --git a/src/utils/serve-functions.js b/src/utils/serve-functions.js index ea8be6328fc..7ed49d43e56 100644 --- a/src/utils/serve-functions.js +++ b/src/utils/serve-functions.js @@ -168,9 +168,24 @@ const shouldBase64Encode = function (contentType) { const BASE_64_MIME_REGEXP = /image|audio|video|application\/pdf|application\/zip|applicaton\/octet-stream/i -const createHandler = async function (dir) { - const functions = await getFunctions(dir) +const RED_BACKGROUND = chalk.red('-background') +const [PRO, BUSINESS, ENTERPRISE] = ['Pro', 'Business', 'Enterprise'].map((plan) => chalk.magenta(plan)) +const BACKGROUND_FUNCTIONS_WARNING = `A serverless function ending in \`${RED_BACKGROUND}\` was detected. +Your team’s current plan doesn’t support Background Functions, which have names ending in \`${RED_BACKGROUND}\`. +To be able to deploy this function successfully either: + - change the function name to remove \`${RED_BACKGROUND}\` and execute it synchronously + - upgrade your team plan to a level that supports Background Functions (${PRO}, ${BUSINESS}, or ${ENTERPRISE}) +` + +const validateFunctions = function ({ functions, capabilities, warn }) { + if (!capabilities.backgroundFunctions && functions.some(({ isBackground }) => isBackground)) { + warn(BACKGROUND_FUNCTIONS_WARNING) + } +} +const createHandler = async function ({ dir, capabilities, warn }) { + const functions = await getFunctions(dir) + validateFunctions({ functions, capabilities, warn }) const watcher = chokidar.watch(dir, { ignored: /node_modules/ }) watcher.on('change', clearCache('modified')).on('unlink', clearCache('deleted')) @@ -244,7 +259,7 @@ const createHandler = async function (dir) { } } -const createFormSubmissionHandler = function (siteInfo) { +const createFormSubmissionHandler = function ({ siteUrl }) { return async function formSubmissionHandler(req, res, next) { if (req.url.startsWith('/.netlify/') || req.method !== 'POST') return next() @@ -339,9 +354,8 @@ const createFormSubmissionHandler = function (siteInfo) { ...fields, ...Object.entries(files).reduce((prev, [name, { url }]) => ({ ...prev, [name]: url }), {}), }).map(([key, val]) => ({ title: capitalize(key), name: key, value: val })), - site_url: siteInfo.ssl_url, + site_url: siteUrl, }, - site: siteInfo, }) req.body = data req.headers = { @@ -355,7 +369,7 @@ const createFormSubmissionHandler = function (siteInfo) { } } -const serveFunctions = async function (dir, siteInfo = {}) { +const getFunctionsServer = async function ({ dir, siteUrl, capabilities, warn }) { const app = express() app.set('query parser', 'simple') @@ -366,7 +380,7 @@ const serveFunctions = async function (dir, siteInfo = {}) { }), ) app.use(bodyParser.raw({ limit: '6mb', type: '*/*' })) - app.use(createFormSubmissionHandler(siteInfo)) + app.use(createFormSubmissionHandler({ siteUrl })) app.use( expressLogging(console, { blacklist: ['/favicon.ico'], @@ -377,7 +391,7 @@ const serveFunctions = async function (dir, siteInfo = {}) { res.status(204).end() }) - app.all('*', await createHandler(dir)) + app.all('*', await createHandler({ dir, capabilities, warn })) return app } @@ -410,48 +424,54 @@ const getBuildFunction = ({ functionBuilder, log }) => { } } -const startFunctionsServer = async ({ settings, site, log, warn, errorExit, siteInfo }) => { - // serve functions from zip-it-and-ship-it - // env variables relies on `url`, careful moving this code - if (settings.functions) { - const functionBuilder = await detectFunctionsBuilder(site.root) - if (functionBuilder) { - log( - `${NETLIFYDEVLOG} Function builder ${chalk.yellow( - functionBuilder.builderName, - )} detected: Running npm script ${chalk.yellow(functionBuilder.npmScript)}`, - ) - warn( - `${NETLIFYDEVWARN} This is a beta feature, please give us feedback on how to improve at https://github.com/netlify/cli/`, - ) - - const debouncedBuild = debounce(getBuildFunction({ functionBuilder, log }), 300, { - leading: true, - trailing: true, - }) +const setupFunctionsBuilder = async ({ site, log, warn }) => { + const functionBuilder = await detectFunctionsBuilder(site.root) + if (functionBuilder) { + log( + `${NETLIFYDEVLOG} Function builder ${chalk.yellow( + functionBuilder.builderName, + )} detected: Running npm script ${chalk.yellow(functionBuilder.npmScript)}`, + ) + warn( + `${NETLIFYDEVWARN} This is a beta feature, please give us feedback on how to improve at https://github.com/netlify/cli/`, + ) - await debouncedBuild() + const debouncedBuild = debounce(getBuildFunction({ functionBuilder, log }), 300, { + leading: true, + trailing: true, + }) - const functionWatcher = chokidar.watch(functionBuilder.src) - functionWatcher.on('ready', () => { - functionWatcher.on('add', debouncedBuild) - functionWatcher.on('change', debouncedBuild) - functionWatcher.on('unlink', debouncedBuild) - }) - } + await debouncedBuild() - const functionsServer = await serveFunctions(settings.functions, siteInfo) + const functionWatcher = chokidar.watch(functionBuilder.src) + functionWatcher.on('ready', () => { + functionWatcher.on('add', debouncedBuild) + functionWatcher.on('change', debouncedBuild) + functionWatcher.on('unlink', debouncedBuild) + }) + } +} - await new Promise((resolve) => { - functionsServer.listen(settings.functionsPort, (err) => { - if (err) { - errorExit(`${NETLIFYDEVERR} Unable to start lambda server: ${err}`) - } else { - log(`${NETLIFYDEVLOG} Functions server is listening on ${settings.functionsPort}`) - } - resolve() - }) +const startServer = async ({ server, settings, log, errorExit }) => { + await new Promise((resolve) => { + server.listen(settings.functionsPort, (err) => { + if (err) { + errorExit(`${NETLIFYDEVERR} Unable to start functions server: ${err}`) + } else { + log(`${NETLIFYDEVLOG} Functions server is listening on ${settings.functionsPort}`) + } + resolve() }) + }) +} + +const startFunctionsServer = async ({ settings, site, log, warn, errorExit, siteUrl, capabilities }) => { + // serve functions from zip-it-and-ship-it + // env variables relies on `url`, careful moving this code + if (settings.functions) { + await setupFunctionsBuilder({ site, log, warn }) + const server = await getFunctionsServer({ dir: settings.functions, siteUrl, capabilities, warn }) + await startServer({ server, settings, log, errorExit }) } } diff --git a/tests/command.deploy.test.js b/tests/command.deploy.test.js index b4401091041..d9dbf3fe347 100644 --- a/tests/command.deploy.test.js +++ b/tests/command.deploy.test.js @@ -1,10 +1,10 @@ const process = require('process') const test = require('ava') -const dotProp = require('dot-prop') const fetch = require('node-fetch') const omit = require('omit.js').default +const { supportsEdgeHandlers } = require('../src/lib/account') const { getToken } = require('../src/utils/command') const callCli = require('./utils/call-cli') @@ -93,12 +93,8 @@ if (process.env.IS_FORK !== 'true') { const EDGE_HANDLER_MIN_LENGTH = 50 if (version >= EDGE_HANDLER_MIN_VERSION) { test.serial('should deploy edge handlers when directory exists', async (t) => { - const { - context: { account }, - } = t - const supportsEdgeHandlers = dotProp.get(account, 'capabilities.edge_handlers.included') - if (!supportsEdgeHandlers) { - console.warn(`Skipping edge handlers deploy test for account ${account.slug}`) + if (!supportsEdgeHandlers(t.context.account)) { + console.warn(`Skipping edge handlers deploy test for account ${t.context.account.slug}`) return } await withSiteBuilder('site-with-public-folder', async (builder) => { diff --git a/tests/command.dev.test.js b/tests/command.dev.test.js index 31d8f6c2959..f29a4101f84 100644 --- a/tests/command.dev.test.js +++ b/tests/command.dev.test.js @@ -507,7 +507,7 @@ testMatrix.forEach(({ args }) => { 'client-ip': '127.0.0.1', connection: 'close', host: `${server.host}:${server.port}`, - 'content-length': '285', + 'content-length': '289', 'content-type': 'application/json', 'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'x-forwarded-for': '::ffff:127.0.0.1', @@ -534,8 +534,8 @@ testMatrix.forEach(({ args }) => { value: 'thing', }, ], + site_url: '', }, - site: {}, }) }) })