From 0d583d9204e8535618fe655862b674ad2da81fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Tue, 10 Jan 2023 13:53:57 +0000 Subject: [PATCH] feat: add `serve` command (#5351) * feat: add `--serve` flag to `dev` command * refactor: simplify internal directory clean up * refactor: load built functions * refactor: make things nicer * refactor: add const * feat: show debug message * refactor: rename variable * chore: add `extract-zip` dependency * chore: update snapshots * refactor: conditionally set `quiet` flag * refactor: simplify code * chore: update docs * refactor: rename flag to `prod` * refactor: update message * feat: do not set `NETLIFY_DEV` when `prod` is set * chore: remove lint exceptions * Apply suggestions from code review Co-authored-by: Matt Kane * refactor: treat `NETLIFY_DEV` consistently across function types * refactor: add `internal` source for env vars * fix: handle internal vars correctly * chore: update docs * refactor: remove unused variable * feat: set `context=production` when `prod` is used * refactor: add beta label * feat: create separate `serve` command * Update src/commands/serve/serve.mjs Co-authored-by: Matt Kane * refactor: remove unused flags * refactor: abort server when build fails * refactor: always start functions server * chore: remove ESLint directive * fix: fix imports * refactor: always start functions server * refactor: ensure internal functions directory * refactor: reinstate functions server check * refactor: add missing import * fix: fix imports * refactor: move logic for ensuring internal functions directory * chore: fix test * chore: update docs * chore: update snapshot * refactor: add log message * Update src/commands/serve/serve.mjs Co-authored-by: Matt Kane * chore: update docs Co-authored-by: Matt Kane --- README.md | 5 + docs/README.md | 4 + docs/commands/dev.md | 1 - docs/commands/index.md | 4 + src/commands/base-command.mjs | 5 +- src/commands/dev/dev.mjs | 652 +----------------- src/commands/main.mjs | 2 + src/commands/serve/serve.mjs | 189 +++++ src/utils/banner.mjs | 17 + src/utils/detect-server-settings.mjs | 36 +- src/utils/framework-server.mjs | 66 ++ src/utils/functions/functions.mjs | 8 +- src/utils/graph.mjs | 170 +++++ src/utils/proxy-server.mjs | 90 +++ src/utils/run-build.mjs | 129 ++++ src/utils/shell.mjs | 120 ++++ src/utils/static-server.mjs | 34 + src/utils/validation.mjs | 15 + .../600.framework-detection.test.cjs | 15 +- .../600.framework-detection.test.cjs.md | 6 +- .../600.framework-detection.test.cjs.snap | Bin 2108 -> 2160 bytes tests/integration/utils/dev-server.cjs | 13 +- 22 files changed, 923 insertions(+), 658 deletions(-) create mode 100644 src/commands/serve/serve.mjs create mode 100644 src/utils/banner.mjs create mode 100644 src/utils/framework-server.mjs create mode 100644 src/utils/graph.mjs create mode 100644 src/utils/proxy-server.mjs create mode 100644 src/utils/run-build.mjs create mode 100644 src/utils/shell.mjs create mode 100644 src/utils/static-server.mjs create mode 100644 src/utils/validation.mjs diff --git a/README.md b/README.md index 89e4cc25907..1245de122c2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ See the [CLI command line reference](https://cli.netlify.com/commands/) to get s - [login](#login) - [open](#open) - [recipes](#recipes) + - [serve](#serve) - [sites](#sites) - [status](#status) - [switch](#switch) @@ -219,6 +220,10 @@ Open settings for the site linked to the current folder | [`recipes:list`](/docs/commands/recipes.md#recipeslist) | (Beta) List the recipes available to create and modify files in a project | +### [serve](/docs/commands/serve.md) + +(Beta) Build the site for production and serve locally. This does not watch the code for changes, so if you need to rebuild your site then you must exit and run `serve` again. + ### [sites](/docs/commands/sites.md) Handle various site operations diff --git a/docs/README.md b/docs/README.md index 754bb421184..21656429d10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -168,6 +168,10 @@ Open settings for the site linked to the current folder | [`recipes:list`](/docs/commands/recipes.md#recipeslist) | (Beta) List the recipes available to create and modify files in a project | +### [serve](/docs/commands/serve.md) + +(Beta) Build the site for production and serve locally. This does not watch the code for changes, so if you need to rebuild your site then you must exit and run `serve` again. + ### [sites](/docs/commands/sites.md) Handle various site operations diff --git a/docs/commands/dev.md b/docs/commands/dev.md index b1b1f6f288f..637361953c4 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -30,7 +30,6 @@ netlify dev - `live` (*boolean*) - start a public live session - `offline` (*boolean*) - disables any features that require network access - `port` (*string*) - port of netlify dev -- `prod` (*boolean*) - (Beta) build the site for production and serve locally - `sessionId` (*string*) - (Graph) connect to cloud session with ID [sessionId] - `targetPort` (*string*) - port of target app server - `debug` (*boolean*) - Print debugging information diff --git a/docs/commands/index.md b/docs/commands/index.md index 3283866d71d..544f2120472 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -147,6 +147,10 @@ Open settings for the site linked to the current folder | [`recipes:list`](/docs/commands/recipes.md#recipeslist) | (Beta) List the recipes available to create and modify files in a project | +### [serve](/docs/commands/serve.md) + +(Beta) Build the site for production and serve locally. This does not watch the code for changes, so if you need to rebuild your site then you must exit and run `serve` again. + ### [sites](/docs/commands/sites.md) Handle various site operations diff --git a/src/commands/base-command.mjs b/src/commands/base-command.mjs index 93c12f4f848..774a51b0947 100644 --- a/src/commands/base-command.mjs +++ b/src/commands/base-command.mjs @@ -510,10 +510,7 @@ export default class BaseCommand extends Command { * @returns {string} */ getDefaultContext() { - const { prod } = this.opts() - const isDevCommand = ['dev', 'dev:exec'].includes(this.name()) - - if (isDevCommand && prod) { + if (this.name() === 'serve') { return 'production' } diff --git a/src/commands/dev/dev.mjs b/src/commands/dev/dev.mjs index c56db4bc41a..d653eb667f8 100644 --- a/src/commands/dev/dev.mjs +++ b/src/commands/dev/dev.mjs @@ -1,317 +1,35 @@ // @ts-check -import events from 'events' -import { promises as fs } from 'fs' -import path from 'path' import process from 'process' -import fastifyStatic from '@fastify/static' -import boxen from 'boxen' import { Option } from 'commander' -import execa from 'execa' -import Fastify from 'fastify' -import stripAnsiCc from 'strip-ansi-control-characters' -import waitPort from 'wait-port' -import { INTERNAL_EDGE_FUNCTIONS_FOLDER } from '../../lib/edge-functions/consts.mjs' import { promptEditorHelper } from '../../lib/edge-functions/editor-helper.mjs' import { startFunctionsServer } from '../../lib/functions/server.mjs' -import { - OneGraphCliClient, - loadCLISession, - markCliSessionInactive, - persistNewOperationsDocForSession, - startOneGraphCLISession, -} from '../../lib/one-graph/cli-client.mjs' -import { - defaultExampleOperationsDoc, - getGraphEditUrlBySiteId, - getNetlifyGraphConfig, - readGraphQLOperationsSourceFile, -} from '../../lib/one-graph/cli-netlify-graph.mjs' -import { getPathInProject } from '../../lib/settings.mjs' -import { startSpinner, stopSpinner } from '../../lib/spinner.mjs' +import { printBanner } from '../../utils/banner.mjs' import { BANG, chalk, - error, exit, - getToken, log, NETLIFYDEV, NETLIFYDEVERR, NETLIFYDEVLOG, NETLIFYDEVWARN, normalizeConfig, - warn, - watchDebounced, } from '../../utils/command-helpers.mjs' -import detectServerSettings from '../../utils/detect-server-settings.mjs' -import { generateNetlifyGraphJWT, getSiteInformation, injectEnvVariables, processOnExit } from '../../utils/dev.mjs' +import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.mjs' +import { getSiteInformation, injectEnvVariables } from '../../utils/dev.mjs' import { getEnvelopeEnv, normalizeContext } from '../../utils/env/index.mjs' -import { INTERNAL_FUNCTIONS_FOLDER } from '../../utils/functions/index.mjs' import { ensureNetlifyIgnore } from '../../utils/gitignore.mjs' +import { startNetlifyGraph, startPollingForAPIAuthentication } from '../../utils/graph.mjs' import { startLiveTunnel } from '../../utils/live-tunnel.mjs' import openBrowser from '../../utils/open-browser.mjs' -import { startProxy } from '../../utils/proxy.mjs' +import { generateInspectSettings, startProxyServer } from '../../utils/proxy-server.mjs' +import { runDevTimeline } from '../../utils/run-build.mjs' +import { getGeoCountryArgParser } from '../../utils/validation.mjs' import { createDevExecCommand } from './dev-exec.mjs' -const netlifyBuildPromise = import('@netlify/build') - -const startStaticServer = async ({ settings }) => { - const server = Fastify() - const rootPath = path.resolve(settings.dist) - server.register(fastifyStatic, { - root: rootPath, - etag: false, - acceptRanges: false, - lastModified: false, - }) - - server.setNotFoundHandler((_req, res) => { - res.code(404).sendFile('404.html', rootPath) - }) - - server.addHook('onRequest', (req, reply, done) => { - reply.header('X-Powered-by', 'netlify-dev') - const validMethods = ['GET', 'HEAD'] - if (!validMethods.includes(req.method)) { - reply.code(405).send('Method Not Allowed') - } - done() - }) - - await server.listen({ port: settings.frameworkPort }) - log(`\n${NETLIFYDEVLOG} Static server listening to`, settings.frameworkPort) -} - -const isNonExistingCommandError = ({ command, error: commandError }) => { - // `ENOENT` is only returned for non Windows systems - // See https://github.com/sindresorhus/execa/pull/447 - if (commandError.code === 'ENOENT') { - return true - } - - // if the command is a package manager we let it report the error - if (['yarn', 'npm'].includes(command)) { - return false - } - - // this only works on English versions of Windows - return ( - typeof commandError.message === 'string' && - commandError.message.includes('is not recognized as an internal or external command') - ) -} - -/** - * @type {(() => Promise)[]} - array of functions to run before the process exits - */ -const cleanupWork = [] - -let cleanupStarted = false - -/** - * @param {object} input - * @param {number=} input.exitCode The exit code to return when exiting the process after cleanup - */ -const cleanupBeforeExit = async ({ exitCode }) => { - // If cleanup has started, then wherever started it will be responsible for exiting - if (!cleanupStarted) { - cleanupStarted = true - try { - // eslint-disable-next-line no-unused-vars - const cleanupFinished = await Promise.all(cleanupWork.map((cleanup) => cleanup())) - } finally { - process.exit(exitCode) - } - } -} - -/** - * Run a command and pipe stdout, stderr and stdin - * @param {string} command - * @param {NodeJS.ProcessEnv} env - * @returns {execa.ExecaChildProcess} - */ -const runCommand = (command, env = {}, spinner = null) => { - const commandProcess = execa.command(command, { - preferLocal: true, - // we use reject=false to avoid rejecting synchronously when the command doesn't exist - reject: false, - env, - // windowsHide needs to be false for child process to terminate properly on Windows - windowsHide: false, - }) - - // This ensures that an active spinner stays at the bottom of the commandline - // even though the actual framework command might be outputting stuff - const pipeDataWithSpinner = (writeStream, chunk) => { - if (spinner && spinner.isSpinning) { - spinner.clear() - spinner.isSilent = true - } - writeStream.write(chunk, () => { - if (spinner && spinner.isSpinning) { - spinner.isSilent = false - spinner.render() - } - }) - } - - commandProcess.stdout.pipe(stripAnsiCc.stream()).on('data', pipeDataWithSpinner.bind(null, process.stdout)) - commandProcess.stderr.pipe(stripAnsiCc.stream()).on('data', pipeDataWithSpinner.bind(null, process.stderr)) - process.stdin.pipe(commandProcess.stdin) - - // we can't try->await->catch since we don't want to block on the framework server which - // is a long running process - // eslint-disable-next-line promise/catch-or-return - commandProcess - // eslint-disable-next-line promise/prefer-await-to-then - .then(async () => { - const result = await commandProcess - const [commandWithoutArgs] = command.split(' ') - if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { - log( - NETLIFYDEVERR, - `Failed running command: ${command}. Please verify ${chalk.magenta(`'${commandWithoutArgs}'`)} exists`, - ) - } else { - const errorMessage = result.failed - ? `${NETLIFYDEVERR} ${result.shortMessage}` - : `${NETLIFYDEVWARN} "${command}" exited with code ${result.exitCode}` - - log(`${errorMessage}. Shutting down Netlify Dev server`) - } - - return await cleanupBeforeExit({ exitCode: 1 }) - }) - processOnExit(async () => await cleanupBeforeExit({})) - - return commandProcess -} - -/** - * @typedef StartReturnObject - * @property {4 | 6 | undefined=} ipVersion The version the open port was found on - */ - -/** - * Start a static server if the `useStaticServer` is provided or a framework specific server - * @param {object} config - * @param {Partial} config.settings - * @returns {Promise} - */ -const startFrameworkServer = async function ({ settings }) { - if (settings.useStaticServer) { - if (settings.command) { - runCommand(settings.command, settings.env) - } - await startStaticServer({ settings }) - - return {} - } - - log(`${NETLIFYDEVLOG} Starting Netlify Dev with ${settings.framework || 'custom config'}`) - - const spinner = startSpinner({ - text: `Waiting for framework port ${settings.frameworkPort}. This can be configured using the 'targetPort' property in the netlify.toml`, - }) - - runCommand(settings.command, settings.env, spinner) - - let port - try { - port = await waitPort({ - port: settings.frameworkPort, - host: 'localhost', - output: 'silent', - timeout: FRAMEWORK_PORT_TIMEOUT, - ...(settings.pollingStrategies.includes('HTTP') && { protocol: 'http' }), - }) - - if (!port.open) { - throw new Error(`Timed out waiting for port '${settings.frameworkPort}' to be open`) - } - - stopSpinner({ error: false, spinner }) - } catch (error_) { - stopSpinner({ error: true, spinner }) - log(NETLIFYDEVERR, `Netlify Dev could not start or connect to localhost:${settings.frameworkPort}.`) - log(NETLIFYDEVERR, `Please make sure your framework server is running on port ${settings.frameworkPort}`) - error(error_) - exit(1) - } - - return { ipVersion: port?.ipVersion } -} - -// 10 minutes -const FRAMEWORK_PORT_TIMEOUT = 6e5 - -/** - * @typedef {Object} InspectSettings - * @property {boolean} enabled - Inspect enabled - * @property {boolean} pause - Pause on breakpoints - * @property {string|undefined} address - Host/port override (optional) - */ - -/** - * - * @param {object} params - * @param {*} params.addonsUrls - * @param {import('../base-command.mjs').NetlifyOptions["config"]} params.config - * @param {string} [params.configPath] An override for the Netlify config path - * @param {import('../base-command.mjs').NetlifyOptions["cachedConfig"]['env']} params.env - * @param {InspectSettings} params.inspectSettings - * @param {() => Promise} params.getUpdatedConfig - * @param {string} params.geolocationMode - * @param {string} params.geoCountry - * @param {*} params.settings - * @param {boolean} params.offline - * @param {*} params.site - * @param {*} params.siteInfo - * @param {import('../../utils/state-config.mjs').default} params.state - * @returns - */ -const startProxyServer = async ({ - addonsUrls, - config, - configPath, - env, - geoCountry, - geolocationMode, - getUpdatedConfig, - inspectSettings, - offline, - settings, - site, - siteInfo, - state, -}) => { - const url = await startProxy({ - addonsUrls, - config, - configPath: configPath || site.configPath, - env, - geolocationMode, - geoCountry, - getUpdatedConfig, - inspectSettings, - offline, - projectDir: site.root, - settings, - state, - siteInfo, - }) - if (!url) { - log(NETLIFYDEVERR, `Unable to start proxy server on port '${settings.port}'`) - exit(1) - } - - return url -} - /** * * @param {object} config @@ -333,83 +51,6 @@ const handleLiveTunnel = async ({ api, options, settings, site }) => { } } -const printBanner = ({ url }) => { - const banner = chalk.bold(`${NETLIFYDEVLOG} Server now ready on ${url}`) - - log( - boxen(banner, { - padding: 1, - margin: 1, - align: 'center', - borderColor: '#00c7b7', - }), - ) -} - -const startPollingForAPIAuthentication = async function (options) { - const { api, command, config, site, siteInfo } = options - const frequency = 5000 - - const helper = async (maybeSiteData) => { - const siteData = await (maybeSiteData || api.getSite({ siteId: site.id })) - const authlifyTokenId = siteData && siteData.authlify_token_id - - const existingAuthlifyTokenId = config && config.netlifyGraphConfig && config.netlifyGraphConfig.authlifyTokenId - if (authlifyTokenId && authlifyTokenId !== existingAuthlifyTokenId) { - const netlifyToken = await command.authenticate() - // Only inject the authlify config if a token ID exists. This prevents - // calling command.authenticate() (which opens a browser window) if the - // user hasn't enabled API Authentication - const netlifyGraphConfig = { - netlifyToken, - authlifyTokenId: siteData.authlify_token_id, - siteId: site.id, - } - config.netlifyGraphConfig = netlifyGraphConfig - - const netlifyGraphJWT = generateNetlifyGraphJWT(netlifyGraphConfig) - - if (netlifyGraphJWT != null) { - // XXX(anmonteiro): this name is deprecated. Delete after 3/31/2022 - process.env.ONEGRAPH_AUTHLIFY_TOKEN = netlifyGraphJWT - process.env.NETLIFY_GRAPH_TOKEN = netlifyGraphJWT - } - } else if (!authlifyTokenId) { - // If there's no `authlifyTokenId`, it's because the user disabled API - // Auth. Delete the config in this case. - delete config.netlifyGraphConfig - } - - setTimeout(helper, frequency) - } - - await helper(siteInfo) -} - -/** - * @param {boolean|string} edgeInspect - * @param {boolean|string} edgeInspectBrk - * @returns {InspectSettings} - */ -const generateInspectSettings = (edgeInspect, edgeInspectBrk) => { - const enabled = Boolean(edgeInspect) || Boolean(edgeInspectBrk) - const pause = Boolean(edgeInspectBrk) - const getAddress = () => { - if (edgeInspect) { - return typeof edgeInspect === 'string' ? edgeInspect : undefined - } - if (edgeInspectBrk) { - return typeof edgeInspectBrk === 'string' ? edgeInspectBrk : undefined - } - } - - return { - enabled, - pause, - address: getAddress(), - } -} - const validateShortFlagArgs = (args) => { if (args.startsWith('=')) { throw new Error( @@ -426,19 +67,6 @@ const validateShortFlagArgs = (args) => { return args } -const validateGeoCountryCode = (arg) => { - // Validate that the arg passed is two letters only for country - // See https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes - if (!/^[a-z]{2}$/i.test(arg)) { - throw new Error( - `The geo country code must use a two letter abbreviation. - ${chalk.red(BANG)} Example: - netlify dev --geo=mock --country=FR`, - ) - } - return arg.toUpperCase() -} - /** * The dev command * @param {import('commander').OptionValues} options @@ -460,22 +88,7 @@ const dev = async (options, command) => { let { env } = cachedConfig - if (!options.prod) { - // Add `NETLIFY_DEV` to the environment variables. - env.NETLIFY_DEV = { sources: ['internal'], value: 'true' } - - // Ensure the internal functions directory exists so that any functions - // created by Netlify Build are loaded. - const fullPath = path.resolve(site.root, INTERNAL_FUNCTIONS_FOLDER) - - await fs.mkdir(fullPath, { recursive: true }) - } - - // If the `prod` flag is present, we override the `framework` value so that - // we start a static server and not the framework's development server. - if (options.prod) { - devConfig.framework = '#static' - } + env.NETLIFY_DEV = { sources: ['internal'], value: 'true' } if (!options.offline && siteInfo.use_envelope) { env = await getEnvelopeEnv({ api, context: options.context, env, siteInfo }) @@ -498,25 +111,7 @@ const dev = async (options, command) => { try { settings = await detectServerSettings(devConfig, options, site.root) - // If there are plugins that we should be running for this site, add them - // to the config as if they were declared in netlify.toml. We must check - // whether the plugin has already been added by another source (like the - // TOML file or the UI), as we don't want to run the same plugin twice. - if (settings.plugins) { - const { plugins: existingPlugins = [] } = cachedConfig.config - const existingPluginNames = new Set(existingPlugins.map((plugin) => plugin.package)) - const newPlugins = settings.plugins - .map((pluginName) => { - if (existingPluginNames.has(pluginName)) { - return - } - - return { package: pluginName, origin: 'config', inputs: {} } - }) - .filter(Boolean) - - cachedConfig.config.plugins = [...newPlugins, ...cachedConfig.config.plugins] - } + cachedConfig.config = getConfigWithPlugins(cachedConfig.config, settings) } catch (error_) { log(NETLIFYDEVERR, error_.message) exit(1) @@ -529,24 +124,15 @@ const dev = async (options, command) => { startPollingForAPIAuthentication({ api, command, config, site, siteInfo }) } - if (options.prod) { - log(`${NETLIFYDEVWARN} Building site for production`) - } else { - log(`${NETLIFYDEVWARN} Setting up local development server`) - } - - const { configPath: configPathOverride } = await runBuild({ cachedConfig, options, settings, site }) + log(`${NETLIFYDEVWARN} Setting up local development server`) - // When using the `prod` flag, we want to use the production functions built - // by Netlify Build rather than building them from source. - const loadDistFunctions = Boolean(options.prod) + const { configPath: configPathOverride } = await runDevTimeline({ cachedConfig, options, settings, site }) await startFunctionsServer({ api, command, config, debug: options.debug, - loadDistFunctions, settings, site, siteInfo, @@ -599,214 +185,17 @@ const dev = async (options, command) => { process.env.URL = url process.env.DEPLOY_URL = url - if (startNetlifyGraphWatcher && options.offline) { - warn(`Unable to start Netlify Graph in offline mode`) - } else if (startNetlifyGraphWatcher && !site.id) { - error( - `No siteId defined, unable to start Netlify Graph. To enable, run ${chalk.yellow( - 'netlify init', - )} or ${chalk.yellow('netlify link')}.`, - ) - } else if (startNetlifyGraphWatcher) { - const netlifyToken = await command.authenticate() - await OneGraphCliClient.ensureAppForSite(netlifyToken, site.id) - - let stopWatchingCLISessions - - let liveConfig = { ...config } - let isRestartingSession = false - - const createOrResumeSession = async function () { - const netlifyGraphConfig = await getNetlifyGraphConfig({ command, options, settings }) - - let graphqlDocument = readGraphQLOperationsSourceFile(netlifyGraphConfig) - - if (!graphqlDocument || graphqlDocument.trim().length === 0) { - graphqlDocument = defaultExampleOperationsDoc - } - - stopWatchingCLISessions = await startOneGraphCLISession({ - config: liveConfig, - netlifyGraphConfig, - netlifyToken, - site, - state, - oneGraphSessionId: options.sessionId, - }) - - // Should be created by startOneGraphCLISession - const oneGraphSessionId = loadCLISession(state) - - await persistNewOperationsDocForSession({ - config: liveConfig, - netlifyGraphConfig, - netlifyToken, - oneGraphSessionId, - operationsDoc: graphqlDocument, - siteId: site.id, - siteRoot: site.root, - }) - - return oneGraphSessionId - } - - const configWatcher = new events.EventEmitter() - - // Only set up a watcher if we know the config path. - const { configPath } = command.netlify.site - if (configPath) { - // chokidar handle - command.configWatcherHandle = await watchDebounced(configPath, { - depth: 1, - onChange: async () => { - const cwd = options.cwd || process.cwd() - const [token] = await getToken(options.auth) - const { config: newConfig } = await command.getConfig({ cwd, state, token, ...command.netlify.apiUrlOpts }) - - const normalizedNewConfig = normalizeConfig(newConfig) - configWatcher.emit('change', normalizedNewConfig) - }, - }) - - processOnExit(async () => { - await command.configWatcherHandle.close() - }) - } - - // Set up a handler for config changes. - configWatcher.on('change', async (newConfig) => { - command.netlify.config = newConfig - liveConfig = newConfig - if (isRestartingSession) { - return - } - stopWatchingCLISessions && stopWatchingCLISessions() - isRestartingSession = true - await createOrResumeSession() - isRestartingSession = false - }) - - const oneGraphSessionId = await createOrResumeSession() - const cleanupSession = () => markCliSessionInactive({ netlifyToken, sessionId: oneGraphSessionId, siteId: site.id }) - - cleanupWork.push(cleanupSession) - - const graphEditUrl = getGraphEditUrlBySiteId({ siteId: site.id, oneGraphSessionId }) - - log( - `Starting Netlify Graph session, to edit your library visit ${graphEditUrl} or run \`netlify graph:edit\` in another tab`, - ) - } - - printBanner({ url }) -} - -// Copies `netlify.toml`, if one is defined, into the `.netlify` internal -// directory and returns the path to its new location. -const copyConfig = async ({ configPath, siteRoot }) => { - const newConfigPath = path.resolve(siteRoot, getPathInProject(['netlify.toml'])) - - try { - await fs.copyFile(configPath, newConfigPath) - } catch { - // no-op - } - - return newConfigPath -} - -// Loads and runs Netlify Build. Chooses the right flags and entry point based -// on the options supplied. -const runBuild = async ({ cachedConfig, options, settings, site }) => { - const { default: buildSite, startDev } = await netlifyBuildPromise - const sharedOptions = getBuildOptions({ - cachedConfig, + await startNetlifyGraph({ + command, + config, options, - }) - const devCommand = async (settingsOverrides = {}) => { - const { ipVersion } = await startFrameworkServer({ - settings: { - ...settings, - ...settingsOverrides, - }, - }) - - settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1' - } - - if (options.prod) { - // Start by cleaning the internal directory, as it may have artifacts left - // by previous builds. - await cleanInternalDirectory(site.root) - - // Copy `netlify.toml` into the internal directory. This will be the new - // location of the config file for the duration of the command. - const tempConfigPath = await copyConfig({ configPath: cachedConfig.configPath, siteRoot: site.root }) - const buildSiteOptions = { - ...sharedOptions, - outputConfigPath: tempConfigPath, - saveConfig: true, - } - - // Run Netlify Build using the main entry point. - await buildSite(buildSiteOptions) - - // Start the dev server, forcing the usage of a static server as opposed to - // the framework server. - await devCommand({ - command: undefined, - useStaticServer: true, - }) - - return { configPath: tempConfigPath } - } - - const startDevOptions = { - ...sharedOptions, - - // Set `quiet` to suppress non-essential output from Netlify Build unless - // the `debug` flag is set. - quiet: !options.debug, - } - - // Run Netlify Build using the `startDev` entry point. - const { error: startDevError, success } = await startDev(devCommand, startDevOptions) - - if (!success) { - error(`Could not start local development server\n\n${startDevError.message}\n\n${startDevError.stack}`) - } - - return {} -} - -const getBuildOptions = ({ - cachedConfig, - options: { configPath, context, cwd = process.cwd(), debug, dry, offline, quiet, saveConfig }, -}) => ({ - cachedConfig, - configPath, - siteId: cachedConfig.siteInfo.id, - token: cachedConfig.token, - dry, - debug, - context, - mode: 'cli', - telemetry: false, - buffer: false, - offline, - cwd, - quiet, - saveConfig, -}) - -const cleanInternalDirectory = async (basePath) => { - const ops = [INTERNAL_FUNCTIONS_FOLDER, INTERNAL_EDGE_FUNCTIONS_FOLDER, 'netlify.toml'].map((name) => { - const fullPath = path.resolve(basePath, getPathInProject([name])) - - return fs.rm(fullPath, { force: true, recursive: true }) + settings, + site, + startNetlifyGraphWatcher, + state, }) - await Promise.all(ops) + printBanner({ url }) } /** @@ -837,7 +226,6 @@ export 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 of functions server', (value) => Number.parseInt(value)) - .option('-P, --prod', '(Beta) build the site for production and serve locally', false) .addOption( new Option( '--geo ', @@ -850,7 +238,7 @@ export const createDevCommand = (program) => { new Option( '--country ', 'Two-letter country code (https://ntl.fyi/country-codes) to use as mock geolocation (enables --geo=mock automatically)', - ).argParser(validateGeoCountryCode), + ).argParser(getGeoCountryArgParser('netlify dev --geo=mock --country=FR')), ) .addOption( new Option('--staticServerPort ', 'port of the static app server used when no framework is detected') diff --git a/src/commands/main.mjs b/src/commands/main.mjs index 511b866721d..6037bfd65e2 100644 --- a/src/commands/main.mjs +++ b/src/commands/main.mjs @@ -28,6 +28,7 @@ import { createLoginCommand } from './login/index.mjs' import { createLogoutCommand } from './logout/index.mjs' import { createOpenCommand } from './open/index.mjs' import { createRecipesCommand } from './recipes/index.mjs' +import { createServeCommand } from './serve/serve.mjs' import { createSitesCommand } from './sites/index.mjs' import { createStatusCommand } from './status/index.mjs' import { createSwitchCommand } from './switch/index.mjs' @@ -173,6 +174,7 @@ export const createMainCommand = () => { createLoginCommand(program) createLogoutCommand(program) createOpenCommand(program) + createServeCommand(program) createSitesCommand(program) createStatusCommand(program) createSwitchCommand(program) diff --git a/src/commands/serve/serve.mjs b/src/commands/serve/serve.mjs new file mode 100644 index 00000000000..839d2530db2 --- /dev/null +++ b/src/commands/serve/serve.mjs @@ -0,0 +1,189 @@ +// @ts-check +import process from 'process' + +import { Option } from 'commander' + +import { promptEditorHelper } from '../../lib/edge-functions/editor-helper.mjs' +import { startFunctionsServer } from '../../lib/functions/server.mjs' +import { printBanner } from '../../utils/banner.mjs' +import { + chalk, + exit, + log, + NETLIFYDEVERR, + NETLIFYDEVLOG, + NETLIFYDEVWARN, + normalizeConfig, +} from '../../utils/command-helpers.mjs' +import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.mjs' +import { getSiteInformation, injectEnvVariables } from '../../utils/dev.mjs' +import { getEnvelopeEnv, normalizeContext } from '../../utils/env/index.mjs' +import { getInternalFunctionsDir } from '../../utils/functions/functions.mjs' +import { ensureNetlifyIgnore } from '../../utils/gitignore.mjs' +import openBrowser from '../../utils/open-browser.mjs' +import { generateInspectSettings, startProxyServer } from '../../utils/proxy-server.mjs' +import { runBuildTimeline } from '../../utils/run-build.mjs' +import { getGeoCountryArgParser } from '../../utils/validation.mjs' + +/** + * The serve command + * @param {import('commander').OptionValues} options + * @param {import('../base-command.mjs').default} command + */ +const serve = async (options, command) => { + const { api, cachedConfig, config, repositoryRoot, site, siteInfo, state } = command.netlify + config.dev = { ...config.dev } + config.build = { ...config.build } + /** @type {import('../dev/types').DevConfig} */ + const devConfig = { + ...(config.functionsDirectory && { functions: config.functionsDirectory }), + ...(config.build.publish && { publish: config.build.publish }), + ...config.dev, + ...options, + // Override the `framework` value so that we start a static server and not + // the framework's development server. + framework: '#static', + } + + let { env } = cachedConfig + + if (!options.offline && siteInfo.use_envelope) { + env = await getEnvelopeEnv({ api, context: options.context, env, siteInfo }) + log(`${NETLIFYDEVLOG} Injecting environment variable values for ${chalk.yellow('all scopes')}`) + } + + await injectEnvVariables({ devConfig, env, site }) + await promptEditorHelper({ chalk, config, log, NETLIFYDEVLOG, repositoryRoot, state }) + + const { addonsUrls, capabilities, siteUrl, timeouts } = await getSiteInformation({ + // inherited from base command --offline + offline: options.offline, + api, + site, + siteInfo, + }) + + // Ensure the internal functions directory exists so that the functions + // server and registry are initialized, and any functions created by + // Netlify Build are loaded. + await getInternalFunctionsDir({ base: site.root, ensureExists: true }) + + /** @type {Partial} */ + let settings = {} + try { + settings = await detectServerSettings(devConfig, options, site.root) + + cachedConfig.config = getConfigWithPlugins(cachedConfig.config, settings) + } catch (error_) { + log(NETLIFYDEVERR, error_.message) + exit(1) + } + + command.setAnalyticsPayload({ projectType: settings.framework || 'custom', live: options.live, graph: options.graph }) + + log(`${NETLIFYDEVLOG} Building site for production`) + log( + `${NETLIFYDEVWARN} Changes will not be hot-reloaded, so if you need to rebuild your site you must exit and run 'netlify serve' again`, + ) + + const { configPath: configPathOverride } = await runBuildTimeline({ cachedConfig, options, settings, site }) + + await startFunctionsServer({ + api, + command, + config, + debug: options.debug, + loadDistFunctions: true, + settings, + site, + siteInfo, + siteUrl, + capabilities, + timeouts, + }) + + // Try to add `.netlify` to `.gitignore`. + try { + await ensureNetlifyIgnore(repositoryRoot) + } catch { + // no-op + } + + // TODO: We should consolidate this with the existing config watcher. + const getUpdatedConfig = async () => { + const cwd = options.cwd || process.cwd() + const { config: newConfig } = await command.getConfig({ cwd, offline: true, state }) + const normalizedNewConfig = normalizeConfig(newConfig) + + return normalizedNewConfig + } + + const inspectSettings = generateInspectSettings(options.edgeInspect, options.edgeInspectBrk) + const url = await startProxyServer({ + addonsUrls, + config, + configPath: configPathOverride, + env, + geolocationMode: options.geo, + geoCountry: options.country, + getUpdatedConfig, + inspectSettings, + offline: options.offline, + settings, + site, + siteInfo, + state, + }) + + if (devConfig.autoLaunch !== false) { + await openBrowser({ url, silentBrowserNoneError: true }) + } + + process.env.URL = url + process.env.DEPLOY_URL = url + + printBanner({ url }) +} + +/** + * Creates the `netlify serve` command + * @param {import('../base-command.mjs').default} program + * @returns + */ +export const createServeCommand = (program) => + program + .command('serve') + .description( + '(Beta) Build the site for production and serve locally. This does not watch the code for changes, so if you need to rebuild your site then you must exit and run `serve` again.', + ) + .option( + '--context ', + 'Specify a deploy context or branch for environment variables (contexts: "production", "deploy-preview", "branch-deploy", "dev")', + normalizeContext, + ) + .option('-p ,--port ', 'port of netlify dev', (value) => Number.parseInt(value)) + .option('-d ,--dir ', 'dir with static files') + .option('-f ,--functions ', 'specify a functions folder to serve') + .option('-o ,--offline', 'disables any features that require network access') + .option('--functionsPort ', 'port of functions server', (value) => Number.parseInt(value)) + .addOption( + new Option( + '--geo ', + '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( + '--country ', + 'Two-letter country code (https://ntl.fyi/country-codes) to use as mock geolocation (enables --geo=mock automatically)', + ).argParser(getGeoCountryArgParser('netlify dev --geo=mock --country=FR')), + ) + .addOption( + new Option('--staticServerPort ', 'port of the static app server used when no framework is detected') + .argParser((value) => Number.parseInt(value)) + .hideHelp(), + ) + .addExamples(['netlify serve', 'BROWSER=none netlify serve # disable browser auto opening']) + .action(serve) diff --git a/src/utils/banner.mjs b/src/utils/banner.mjs new file mode 100644 index 00000000000..f899d5e097a --- /dev/null +++ b/src/utils/banner.mjs @@ -0,0 +1,17 @@ +// @ts-check +import boxen from 'boxen' + +import { chalk, log, NETLIFYDEVLOG } from './command-helpers.mjs' + +export const printBanner = ({ url }) => { + const banner = chalk.bold(`${NETLIFYDEVLOG} Server now ready on ${url}`) + + log( + boxen(banner, { + padding: 1, + margin: 1, + align: 'center', + borderColor: '#00c7b7', + }), + ) +} diff --git a/src/utils/detect-server-settings.mjs b/src/utils/detect-server-settings.mjs index 1e8c8b073e2..323bb252947 100644 --- a/src/utils/detect-server-settings.mjs +++ b/src/utils/detect-server-settings.mjs @@ -11,7 +11,7 @@ import isPlainObject from 'is-plain-obj' import { NETLIFYDEVWARN, chalk, log } from './command-helpers.mjs' import { acquirePort } from './dev.mjs' -import { getInternalFunctionsDir } from './functions/index.mjs' +import { getInternalFunctionsDir } from './functions/functions.mjs' const formatProperty = (str) => chalk.magenta(`'${str}'`) const formatValue = (str) => chalk.green(`'${str}'`) @@ -364,4 +364,38 @@ const formatSettingsArrForInquirer = function (frameworks) { return formattedArr.flat() } +/** + * Returns a copy of the provided config with any plugins provided by the + * server settings + * @param {*} config + * @param {Partial} settings + * @returns {*} Modified config + */ +export const getConfigWithPlugins = (config, settings) => { + if (!settings.plugins) { + return config + } + + // If there are plugins that we should be running for this site, add them + // to the config as if they were declared in netlify.toml. We must check + // whether the plugin has already been added by another source (like the + // TOML file or the UI), as we don't want to run the same plugin twice. + const { plugins: existingPlugins = [] } = config + const existingPluginNames = new Set(existingPlugins.map((plugin) => plugin.package)) + const newPlugins = settings.plugins + .map((pluginName) => { + if (existingPluginNames.has(pluginName)) { + return + } + + return { package: pluginName, origin: 'config', inputs: {} } + }) + .filter(Boolean) + + return { + ...config, + plugins: [...newPlugins, ...config.plugins], + } +} + export default detectServerSettings diff --git a/src/utils/framework-server.mjs b/src/utils/framework-server.mjs new file mode 100644 index 00000000000..698f5fcc60c --- /dev/null +++ b/src/utils/framework-server.mjs @@ -0,0 +1,66 @@ +// @ts-check +import waitPort from 'wait-port' + +import { startSpinner, stopSpinner } from '../lib/spinner.mjs' + +import { error, exit, log, NETLIFYDEVERR, NETLIFYDEVLOG } from './command-helpers.mjs' +import { runCommand } from './shell.mjs' +import { startStaticServer } from './static-server.mjs' + +// 10 minutes +const FRAMEWORK_PORT_TIMEOUT = 6e5 + +/** + * @typedef StartReturnObject + * @property {4 | 6 | undefined=} ipVersion The version the open port was found on + */ + +/** + * Start a static server if the `useStaticServer` is provided or a framework specific server + * @param {object} config + * @param {Partial} config.settings + * @returns {Promise} + */ +export const startFrameworkServer = async function ({ settings }) { + if (settings.useStaticServer) { + if (settings.command) { + runCommand(settings.command, settings.env) + } + await startStaticServer({ settings }) + + return {} + } + + log(`${NETLIFYDEVLOG} Starting Netlify Dev with ${settings.framework || 'custom config'}`) + + const spinner = startSpinner({ + text: `Waiting for framework port ${settings.frameworkPort}. This can be configured using the 'targetPort' property in the netlify.toml`, + }) + + runCommand(settings.command, settings.env, spinner) + + let port + try { + port = await waitPort({ + port: settings.frameworkPort, + host: 'localhost', + output: 'silent', + timeout: FRAMEWORK_PORT_TIMEOUT, + ...(settings.pollingStrategies.includes('HTTP') && { protocol: 'http' }), + }) + + if (!port.open) { + throw new Error(`Timed out waiting for port '${settings.frameworkPort}' to be open`) + } + + stopSpinner({ error: false, spinner }) + } catch (error_) { + stopSpinner({ error: true, spinner }) + log(NETLIFYDEVERR, `Netlify Dev could not start or connect to localhost:${settings.frameworkPort}.`) + log(NETLIFYDEVERR, `Please make sure your framework server is running on port ${settings.frameworkPort}`) + error(error_) + exit(1) + } + + return { ipVersion: port?.ipVersion } +} diff --git a/src/utils/functions/functions.mjs b/src/utils/functions/functions.mjs index 006e8056797..4324ff65be5 100644 --- a/src/utils/functions/functions.mjs +++ b/src/utils/functions/functions.mjs @@ -1,4 +1,5 @@ // @ts-check +import { promises as fs } from 'fs' import { resolve } from 'path' import { isDirectoryAsync, isFileAsync } from '../../lib/fs.mjs' @@ -36,8 +37,13 @@ export const getFunctionsDistPath = async ({ base }) => { return isDirectory ? path : null } -export const getInternalFunctionsDir = async ({ base }) => { +export const getInternalFunctionsDir = async ({ base, ensureExists }) => { const path = resolve(base, getPathInProject([INTERNAL_FUNCTIONS_FOLDER])) + + if (ensureExists) { + await fs.mkdir(path, { recursive: true }) + } + const isDirectory = await isDirectoryAsync(path) return isDirectory ? path : null diff --git a/src/utils/graph.mjs b/src/utils/graph.mjs new file mode 100644 index 00000000000..9b51bad6481 --- /dev/null +++ b/src/utils/graph.mjs @@ -0,0 +1,170 @@ +// @ts-check +import events from 'events' +import process from 'process' + +import { + OneGraphCliClient, + loadCLISession, + markCliSessionInactive, + persistNewOperationsDocForSession, + startOneGraphCLISession, +} from '../lib/one-graph/cli-client.mjs' +import { + defaultExampleOperationsDoc, + getGraphEditUrlBySiteId, + getNetlifyGraphConfig, + readGraphQLOperationsSourceFile, +} from '../lib/one-graph/cli-netlify-graph.mjs' + +import { chalk, error, getToken, log, normalizeConfig, warn, watchDebounced } from './command-helpers.mjs' +import { generateNetlifyGraphJWT, processOnExit } from './dev.mjs' +import { addCleanupJob } from './shell.mjs' + +export const startPollingForAPIAuthentication = async function (options) { + const { api, command, config, site, siteInfo } = options + const frequency = 5000 + + const helper = async (maybeSiteData) => { + const siteData = await (maybeSiteData || api.getSite({ siteId: site.id })) + const authlifyTokenId = siteData && siteData.authlify_token_id + + const existingAuthlifyTokenId = config && config.netlifyGraphConfig && config.netlifyGraphConfig.authlifyTokenId + if (authlifyTokenId && authlifyTokenId !== existingAuthlifyTokenId) { + const netlifyToken = await command.authenticate() + // Only inject the authlify config if a token ID exists. This prevents + // calling command.authenticate() (which opens a browser window) if the + // user hasn't enabled API Authentication + const netlifyGraphConfig = { + netlifyToken, + authlifyTokenId: siteData.authlify_token_id, + siteId: site.id, + } + config.netlifyGraphConfig = netlifyGraphConfig + + const netlifyGraphJWT = generateNetlifyGraphJWT(netlifyGraphConfig) + + if (netlifyGraphJWT != null) { + // XXX(anmonteiro): this name is deprecated. Delete after 3/31/2022 + process.env.ONEGRAPH_AUTHLIFY_TOKEN = netlifyGraphJWT + process.env.NETLIFY_GRAPH_TOKEN = netlifyGraphJWT + } + } else if (!authlifyTokenId) { + // If there's no `authlifyTokenId`, it's because the user disabled API + // Auth. Delete the config in this case. + delete config.netlifyGraphConfig + } + + setTimeout(helper, frequency) + } + + await helper(siteInfo) +} + +export const startNetlifyGraph = async ({ + command, + config, + options, + settings, + site, + startNetlifyGraphWatcher, + state, +}) => { + if (startNetlifyGraphWatcher && options.offline) { + warn(`Unable to start Netlify Graph in offline mode`) + } else if (startNetlifyGraphWatcher && !site.id) { + error( + `No siteId defined, unable to start Netlify Graph. To enable, run ${chalk.yellow( + 'netlify init', + )} or ${chalk.yellow('netlify link')}.`, + ) + } else if (startNetlifyGraphWatcher) { + const netlifyToken = await command.authenticate() + await OneGraphCliClient.ensureAppForSite(netlifyToken, site.id) + + let stopWatchingCLISessions + + let liveConfig = { ...config } + let isRestartingSession = false + + const createOrResumeSession = async function () { + const netlifyGraphConfig = await getNetlifyGraphConfig({ command, options, settings }) + + let graphqlDocument = readGraphQLOperationsSourceFile(netlifyGraphConfig) + + if (!graphqlDocument || graphqlDocument.trim().length === 0) { + graphqlDocument = defaultExampleOperationsDoc + } + + stopWatchingCLISessions = await startOneGraphCLISession({ + config: liveConfig, + netlifyGraphConfig, + netlifyToken, + site, + state, + oneGraphSessionId: options.sessionId, + }) + + // Should be created by startOneGraphCLISession + const oneGraphSessionId = loadCLISession(state) + + await persistNewOperationsDocForSession({ + config: liveConfig, + netlifyGraphConfig, + netlifyToken, + oneGraphSessionId, + operationsDoc: graphqlDocument, + siteId: site.id, + siteRoot: site.root, + }) + + return oneGraphSessionId + } + + const configWatcher = new events.EventEmitter() + + // Only set up a watcher if we know the config path. + const { configPath } = command.netlify.site + if (configPath) { + // chokidar handle + command.configWatcherHandle = await watchDebounced(configPath, { + depth: 1, + onChange: async () => { + const cwd = options.cwd || process.cwd() + const [token] = await getToken(options.auth) + const { config: newConfig } = await command.getConfig({ cwd, state, token, ...command.netlify.apiUrlOpts }) + + const normalizedNewConfig = normalizeConfig(newConfig) + configWatcher.emit('change', normalizedNewConfig) + }, + }) + + processOnExit(async () => { + await command.configWatcherHandle.close() + }) + } + + // Set up a handler for config changes. + configWatcher.on('change', async (newConfig) => { + command.netlify.config = newConfig + liveConfig = newConfig + if (isRestartingSession) { + return + } + stopWatchingCLISessions && stopWatchingCLISessions() + isRestartingSession = true + await createOrResumeSession() + isRestartingSession = false + }) + + const oneGraphSessionId = await createOrResumeSession() + const cleanupSession = () => markCliSessionInactive({ netlifyToken, sessionId: oneGraphSessionId, siteId: site.id }) + + addCleanupJob(cleanupSession) + + const graphEditUrl = getGraphEditUrlBySiteId({ siteId: site.id, oneGraphSessionId }) + + log( + `Starting Netlify Graph session, to edit your library visit ${graphEditUrl} or run \`netlify graph:edit\` in another tab`, + ) + } +} diff --git a/src/utils/proxy-server.mjs b/src/utils/proxy-server.mjs new file mode 100644 index 00000000000..ff6de57bc81 --- /dev/null +++ b/src/utils/proxy-server.mjs @@ -0,0 +1,90 @@ +// @ts-check +import { exit, log, NETLIFYDEVERR } from './command-helpers.mjs' +import { startProxy } from './proxy.mjs' + +/** + * @typedef {Object} InspectSettings + * @property {boolean} enabled - Inspect enabled + * @property {boolean} pause - Pause on breakpoints + * @property {string|undefined} address - Host/port override (optional) + */ + +/** + * @param {boolean|string} edgeInspect + * @param {boolean|string} edgeInspectBrk + * @returns {InspectSettings} + */ +export const generateInspectSettings = (edgeInspect, edgeInspectBrk) => { + const enabled = Boolean(edgeInspect) || Boolean(edgeInspectBrk) + const pause = Boolean(edgeInspectBrk) + const getAddress = () => { + if (edgeInspect) { + return typeof edgeInspect === 'string' ? edgeInspect : undefined + } + if (edgeInspectBrk) { + return typeof edgeInspectBrk === 'string' ? edgeInspectBrk : undefined + } + } + + return { + enabled, + pause, + address: getAddress(), + } +} + +/** + * + * @param {object} params + * @param {*} params.addonsUrls + * @param {import('../commands/base-command.mjs').NetlifyOptions["config"]} params.config + * @param {string} [params.configPath] An override for the Netlify config path + * @param {import('../commands/base-command.mjs').NetlifyOptions["cachedConfig"]['env']} params.env + * @param {InspectSettings} params.inspectSettings + * @param {() => Promise} params.getUpdatedConfig + * @param {string} params.geolocationMode + * @param {string} params.geoCountry + * @param {*} params.settings + * @param {boolean} params.offline + * @param {*} params.site + * @param {*} params.siteInfo + * @param {import('./state-config.mjs').default} params.state + * @returns + */ +export const startProxyServer = async ({ + addonsUrls, + config, + configPath, + env, + geoCountry, + geolocationMode, + getUpdatedConfig, + inspectSettings, + offline, + settings, + site, + siteInfo, + state, +}) => { + const url = await startProxy({ + addonsUrls, + config, + configPath: configPath || site.configPath, + env, + geolocationMode, + geoCountry, + getUpdatedConfig, + inspectSettings, + offline, + projectDir: site.root, + settings, + state, + siteInfo, + }) + if (!url) { + log(NETLIFYDEVERR, `Unable to start proxy server on port '${settings.port}'`) + exit(1) + } + + return url +} diff --git a/src/utils/run-build.mjs b/src/utils/run-build.mjs new file mode 100644 index 00000000000..085d3030a5d --- /dev/null +++ b/src/utils/run-build.mjs @@ -0,0 +1,129 @@ +// @ts-check +import { promises as fs } from 'fs' +import path from 'path' +import process from 'process' + +import { INTERNAL_EDGE_FUNCTIONS_FOLDER } from '../lib/edge-functions/consts.mjs' +import { getPathInProject } from '../lib/settings.mjs' + +import { error } from './command-helpers.mjs' +import { startFrameworkServer } from './framework-server.mjs' +import { INTERNAL_FUNCTIONS_FOLDER } from './functions/index.mjs' + +const netlifyBuildPromise = import('@netlify/build') + +// Copies `netlify.toml`, if one is defined, into the `.netlify` internal +// directory and returns the path to its new location. +const copyConfig = async ({ configPath, siteRoot }) => { + const newConfigPath = path.resolve(siteRoot, getPathInProject(['netlify.toml'])) + + try { + await fs.copyFile(configPath, newConfigPath) + } catch { + // no-op + } + + return newConfigPath +} + +const cleanInternalDirectory = async (basePath) => { + const ops = [INTERNAL_FUNCTIONS_FOLDER, INTERNAL_EDGE_FUNCTIONS_FOLDER, 'netlify.toml'].map((name) => { + const fullPath = path.resolve(basePath, getPathInProject([name])) + + return fs.rm(fullPath, { force: true, recursive: true }) + }) + + await Promise.all(ops) +} + +const getBuildOptions = ({ + cachedConfig, + options: { configPath, context, cwd = process.cwd(), debug, dry, offline, quiet, saveConfig }, +}) => ({ + cachedConfig, + configPath, + siteId: cachedConfig.siteInfo.id, + token: cachedConfig.token, + dry, + debug, + context, + mode: 'cli', + telemetry: false, + buffer: false, + offline, + cwd, + quiet, + saveConfig, +}) + +const runNetlifyBuild = async ({ cachedConfig, options, settings, site, timeline = 'build' }) => { + const { default: buildSite, startDev } = await netlifyBuildPromise + const sharedOptions = getBuildOptions({ + cachedConfig, + options, + }) + const devCommand = async (settingsOverrides = {}) => { + const { ipVersion } = await startFrameworkServer({ + settings: { + ...settings, + ...settingsOverrides, + }, + }) + + settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1' + } + + if (timeline === 'build') { + // Start by cleaning the internal directory, as it may have artifacts left + // by previous builds. + await cleanInternalDirectory(site.root) + + // Copy `netlify.toml` into the internal directory. This will be the new + // location of the config file for the duration of the command. + const tempConfigPath = await copyConfig({ configPath: cachedConfig.configPath, siteRoot: site.root }) + const buildSiteOptions = { + ...sharedOptions, + outputConfigPath: tempConfigPath, + saveConfig: true, + } + + // Run Netlify Build using the main entry point. + const { success } = await buildSite(buildSiteOptions) + + if (!success) { + error('Could not start local server due to a build error') + + return {} + } + + // Start the dev server, forcing the usage of a static server as opposed to + // the framework server. + await devCommand({ + command: undefined, + useStaticServer: true, + }) + + return { configPath: tempConfigPath } + } + + const startDevOptions = { + ...sharedOptions, + + // Set `quiet` to suppress non-essential output from Netlify Build unless + // the `debug` flag is set. + quiet: !options.debug, + } + + // Run Netlify Build using the `startDev` entry point. + const { error: startDevError, success } = await startDev(devCommand, startDevOptions) + + if (!success) { + error(`Could not start local development server\n\n${startDevError.message}\n\n${startDevError.stack}`) + } + + return {} +} + +export const runDevTimeline = (options) => runNetlifyBuild({ ...options, timeline: 'dev' }) + +export const runBuildTimeline = (options) => runNetlifyBuild({ ...options, timeline: 'build' }) diff --git a/src/utils/shell.mjs b/src/utils/shell.mjs new file mode 100644 index 00000000000..f765cb7f0ed --- /dev/null +++ b/src/utils/shell.mjs @@ -0,0 +1,120 @@ +// @ts-check +import process from 'process' + +import execa from 'execa' +import stripAnsiCc from 'strip-ansi-control-characters' + +import { chalk, log, NETLIFYDEVERR, NETLIFYDEVWARN } from './command-helpers.mjs' +import { processOnExit } from './dev.mjs' + +/** + * @type {(() => Promise)[]} - array of functions to run before the process exits + */ +const cleanupWork = [] + +let cleanupStarted = false + +/** + * @param {() => Promise} job + */ +export const addCleanupJob = (job) => { + cleanupWork.push(job) +} + +/** + * @param {object} input + * @param {number=} input.exitCode The exit code to return when exiting the process after cleanup + */ +const cleanupBeforeExit = async ({ exitCode }) => { + // If cleanup has started, then wherever started it will be responsible for exiting + if (!cleanupStarted) { + cleanupStarted = true + try { + await Promise.all(cleanupWork.map((cleanup) => cleanup())) + } finally { + process.exit(exitCode) + } + } +} + +/** + * Run a command and pipe stdout, stderr and stdin + * @param {string} command + * @param {NodeJS.ProcessEnv} env + * @returns {execa.ExecaChildProcess} + */ +export const runCommand = (command, env = {}, spinner = null) => { + const commandProcess = execa.command(command, { + preferLocal: true, + // we use reject=false to avoid rejecting synchronously when the command doesn't exist + reject: false, + env, + // windowsHide needs to be false for child process to terminate properly on Windows + windowsHide: false, + }) + + // This ensures that an active spinner stays at the bottom of the commandline + // even though the actual framework command might be outputting stuff + const pipeDataWithSpinner = (writeStream, chunk) => { + if (spinner && spinner.isSpinning) { + spinner.clear() + spinner.isSilent = true + } + writeStream.write(chunk, () => { + if (spinner && spinner.isSpinning) { + spinner.isSilent = false + spinner.render() + } + }) + } + + commandProcess.stdout.pipe(stripAnsiCc.stream()).on('data', pipeDataWithSpinner.bind(null, process.stdout)) + commandProcess.stderr.pipe(stripAnsiCc.stream()).on('data', pipeDataWithSpinner.bind(null, process.stderr)) + process.stdin.pipe(commandProcess.stdin) + + // we can't try->await->catch since we don't want to block on the framework server which + // is a long running process + // eslint-disable-next-line promise/catch-or-return + commandProcess + // eslint-disable-next-line promise/prefer-await-to-then + .then(async () => { + const result = await commandProcess + const [commandWithoutArgs] = command.split(' ') + if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { + log( + NETLIFYDEVERR, + `Failed running command: ${command}. Please verify ${chalk.magenta(`'${commandWithoutArgs}'`)} exists`, + ) + } else { + const errorMessage = result.failed + ? `${NETLIFYDEVERR} ${result.shortMessage}` + : `${NETLIFYDEVWARN} "${command}" exited with code ${result.exitCode}` + + log(`${errorMessage}. Shutting down Netlify Dev server`) + } + + return await cleanupBeforeExit({ exitCode: 1 }) + }) + processOnExit(async () => await cleanupBeforeExit({})) + + return commandProcess +} + +const isNonExistingCommandError = ({ command, error: commandError }) => { + // `ENOENT` is only returned for non Windows systems + // See https://github.com/sindresorhus/execa/pull/447 + if (commandError.code === 'ENOENT') { + return true + } + + // if the command is a package manager we let it report the error + if (['yarn', 'npm'].includes(command)) { + return false + } + + // this only works on English versions of Windows + return ( + typeof commandError.message === 'string' && + commandError.message.includes('is not recognized as an internal or external command') + ) +} diff --git a/src/utils/static-server.mjs b/src/utils/static-server.mjs new file mode 100644 index 00000000000..cc42937485a --- /dev/null +++ b/src/utils/static-server.mjs @@ -0,0 +1,34 @@ +// @ts-check +import path from 'path' + +import fastifyStatic from '@fastify/static' +import Fastify from 'fastify' + +import { log, NETLIFYDEVLOG } from './command-helpers.mjs' + +export const startStaticServer = async ({ settings }) => { + const server = Fastify() + const rootPath = path.resolve(settings.dist) + server.register(fastifyStatic, { + root: rootPath, + etag: false, + acceptRanges: false, + lastModified: false, + }) + + server.setNotFoundHandler((_req, res) => { + res.code(404).sendFile('404.html', rootPath) + }) + + server.addHook('onRequest', (req, reply, done) => { + reply.header('X-Powered-by', 'netlify-dev') + const validMethods = ['GET', 'HEAD'] + if (!validMethods.includes(req.method)) { + reply.code(405).send('Method Not Allowed') + } + done() + }) + + await server.listen({ port: settings.frameworkPort }) + log(`\n${NETLIFYDEVLOG} Static server listening to`, settings.frameworkPort) +} diff --git a/src/utils/validation.mjs b/src/utils/validation.mjs new file mode 100644 index 00000000000..cd64d9fe794 --- /dev/null +++ b/src/utils/validation.mjs @@ -0,0 +1,15 @@ +// @ts-check +import { BANG, chalk } from './command-helpers.mjs' + +export const getGeoCountryArgParser = (exampleCommand) => (arg) => { + // Validate that the arg passed is two letters only for country + // See https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes + if (!/^[a-z]{2}$/i.test(arg)) { + throw new Error( + `The geo country code must use a two letter abbreviation. + ${chalk.red(BANG)} Example: + ${exampleCommand}`, + ) + } + return arg.toUpperCase() +} diff --git a/tests/integration/600.framework-detection.test.cjs b/tests/integration/600.framework-detection.test.cjs index c220ddd6754..e443463f237 100644 --- a/tests/integration/600.framework-detection.test.cjs +++ b/tests/integration/600.framework-detection.test.cjs @@ -333,7 +333,7 @@ test('should start static service for frameworks without port, detected framewor }) }) -test('should run and serve a production build when the `--prod` flag is set', async (t) => { +test('should run and serve a production build when using the `serve` command', async (t) => { await withSiteBuilder('site-with-framework', async (builder) => { await builder .withNetlifyToml({ @@ -372,11 +372,14 @@ test('should run and serve a production build when the `--prod` flag is set', as }) .buildAsync() - await withDevServer({ cwd: builder.directory, context: null, debug: true, prod: true }, async ({ output, url }) => { - const response = await got(`${url}/hello`).json() - t.deepEqual(response, { CONTEXT_CHECK: 'PRODUCTION' }) + await withDevServer( + { cwd: builder.directory, context: null, debug: true, serve: true }, + async ({ output, url }) => { + const response = await got(`${url}/hello`).json() + t.deepEqual(response, { CONTEXT_CHECK: 'PRODUCTION' }) - t.snapshot(normalize(output, { duration: true, filePath: true })) - }) + t.snapshot(normalize(output, { duration: true, filePath: true })) + }, + ) }) }) diff --git a/tests/integration/snapshots/600.framework-detection.test.cjs.md b/tests/integration/snapshots/600.framework-detection.test.cjs.md index 626a9b17c4d..6a4df08cd00 100644 --- a/tests/integration/snapshots/600.framework-detection.test.cjs.md +++ b/tests/integration/snapshots/600.framework-detection.test.cjs.md @@ -238,15 +238,15 @@ Generated by [AVA](https://avajs.dev). 14␊ ◈ "npm run dev" exited with code *. Shutting down Netlify Dev server` -## should run and serve a production build when the `--prod` flag is set +## should run and serve a production build when using the `serve` command > Snapshot 1 - `◈ Netlify Dev ◈␊ - ◈ Injected netlify.toml file env var: CONTEXT_CHECK␊ + `◈ Injected netlify.toml file env var: CONTEXT_CHECK␊ ◈ Using simple static server because '[dev.framework]' was set to '#static'␊ ◈ Running static server from "site-with-framework/public"␊ ◈ Building site for production␊ + ◈ Changes will not be hot-reloaded, so if you need to rebuild your site you must exit and run 'netlify serve' again␊ ​␊ Netlify Build ␊ ────────────────────────────────────────────────────────────────␊ diff --git a/tests/integration/snapshots/600.framework-detection.test.cjs.snap b/tests/integration/snapshots/600.framework-detection.test.cjs.snap index 97490bb2fa7419083592d4c303bf264dc3aeaf13..dcbfea67e1dba40fd05939ae3d6ca42107c3a6ee 100644 GIT binary patch literal 2160 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 literal 2108 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 diff --git a/tests/integration/utils/dev-server.cjs b/tests/integration/utils/dev-server.cjs index 9299997b879..bc6fff19188 100644 --- a/tests/integration/utils/dev-server.cjs +++ b/tests/integration/utils/dev-server.cjs @@ -29,7 +29,7 @@ const startServer = async ({ env = {}, args = [], expectFailure = false, - prod = false, + serve = false, prompt, }) => { const port = await getPort() @@ -39,15 +39,8 @@ const startServer = async ({ console.log(`Starting dev server on port: ${port} in directory ${path.basename(cwd)}`) - const baseArgs = [ - 'dev', - offline ? '--offline' : '', - '-p', - port, - '--staticServerPort', - staticPort, - prod ? '--prod' : '', - ] + const baseCommand = serve ? 'serve' : 'dev' + const baseArgs = [baseCommand, offline ? '--offline' : '', '-p', port, '--staticServerPort', staticPort] // We use `null` to override the default context and actually omit the flag // from the command, which is useful in some test scenarios.