UNPKG

netlify-cli

Version:

Netlify command line tool

472 lines (418 loc) • 16.5 kB
const { Buffer } = require('buffer') const querystring = require('querystring') const { Readable } = require('stream') const { URL } = require('url') const bodyParser = require('body-parser') const chalk = require('chalk') const chokidar = require('chokidar') const { parse: parseContentType } = require('content-type') const express = require('express') const expressLogging = require('express-logging') const jwtDecode = require('jwt-decode') const lambdaLocal = require('lambda-local') const debounce = require('lodash/debounce') const multiparty = require('multiparty') const getRawBody = require('raw-body') const winston = require('winston') const { getLogMessage } = require('../lib/log') const { detectFunctionsBuilder } = require('./detect-functions-builder') const { getFunctions } = require('./get-functions') const { NETLIFYDEVLOG, NETLIFYDEVWARN, NETLIFYDEVERR } = require('./logo') const formatLambdaLocalError = (err) => `${err.errorType}: ${err.errorMessage}\n ${err.stackTrace.join('\n ')}` const handleErr = function (err, response) { response.statusCode = 500 const errorString = typeof err === 'string' ? err : formatLambdaLocalError(err) response.end(errorString) } const formatLambdaError = (err) => chalk.red(`${err.errorType}: ${err.errorMessage}`) const styleFunctionName = (name) => chalk.magenta(name) const capitalize = function (t) { return t.replace(/(^\w|\s\w)/g, (string) => string.toUpperCase()) } const validateLambdaResponse = (lambdaResponse) => { if (lambdaResponse === undefined) { return { error: 'lambda response was undefined. check your function code again' } } if (!Number(lambdaResponse.statusCode)) { return { error: `Your function response must have a numerical statusCode. You gave: $ ${lambdaResponse.statusCode}`, } } if (lambdaResponse.body && typeof lambdaResponse.body !== 'string') { return { error: `Your function response must have a string body. You gave: ${lambdaResponse.body}` } } return {} } const createSynchronousFunctionCallback = function (response) { return function callbackHandler(err, lambdaResponse) { if (err) { return handleErr(err, response) } const { error } = validateLambdaResponse(lambdaResponse) if (error) { console.log(`${NETLIFYDEVERR} ${error}`) return handleErr(error, response) } response.statusCode = lambdaResponse.statusCode for (const key in lambdaResponse.headers) { response.setHeader(key, lambdaResponse.headers[key]) } for (const key in lambdaResponse.multiValueHeaders) { const items = lambdaResponse.multiValueHeaders[key] response.setHeader(key, items) } if (lambdaResponse.body) { response.write(lambdaResponse.isBase64Encoded ? Buffer.from(lambdaResponse.body, 'base64') : lambdaResponse.body) } response.end() } } const createBackgroundFunctionCallback = (functionName) => { return (err) => { if (err) { console.log( `${NETLIFYDEVERR} Error during background function ${styleFunctionName(functionName)} execution:`, formatLambdaError(err), ) } else { console.log(`${NETLIFYDEVLOG} Done executing background function ${styleFunctionName(functionName)}`) } } } const DEFAULT_LAMBDA_OPTIONS = { verboseLevel: 3, } // 10 seconds for synchronous functions const SYNCHRONOUS_FUNCTION_TIMEOUT = 1e4 const executeSynchronousFunction = ({ event, lambdaPath, clientContext, response }) => { return lambdaLocal.execute({ ...DEFAULT_LAMBDA_OPTIONS, event, lambdaPath, clientContext, callback: createSynchronousFunctionCallback(response), timeoutMs: SYNCHRONOUS_FUNCTION_TIMEOUT, }) } // 15 minuets for background functions const BACKGROUND_FUNCTION_TIMEOUT = 9e5 const BACKGROUND_FUNCTION_STATUS_CODE = 202 const executeBackgroundFunction = ({ event, lambdaPath, clientContext, response, functionName }) => { console.log(`${NETLIFYDEVLOG} Queueing background function ${styleFunctionName(functionName)} for execution`) response.status(BACKGROUND_FUNCTION_STATUS_CODE) response.end() return lambdaLocal.execute({ ...DEFAULT_LAMBDA_OPTIONS, event, lambdaPath, clientContext, callback: createBackgroundFunctionCallback(functionName), timeoutMs: BACKGROUND_FUNCTION_TIMEOUT, }) } const buildClientContext = function (headers) { // inject a client context based on auth header, ported over from netlify-lambda (https://github.com/netlify/netlify-lambda/pull/57) if (!headers.authorization) return const parts = headers.authorization.split(' ') if (parts.length !== 2 || parts[0] !== 'Bearer') return try { return { identity: { url: 'https://netlify-dev-locally-emulated-identity.netlify.com/.netlify/identity', token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJuZXRsaWZ5IGRldiIsInRlc3REYXRhIjoiTkVUTElGWV9ERVZfTE9DQUxMWV9FTVVMQVRFRF9JREVOVElUWSJ9.2eSDqUOZAOBsx39FHFePjYj12k0LrxldvGnlvDu3GMI', // you can decode this with https://jwt.io/ // just says // { // "source": "netlify dev", // "testData": "NETLIFY_DEV_LOCALLY_EMULATED_IDENTITY" // } }, user: jwtDecode(parts[1]), } } catch (_) { // Ignore errors - bearer token is not a JWT, probably not intended for us } } const clearCache = (action) => (path) => { console.log(`${NETLIFYDEVLOG} ${path} ${action}, reloading...`) Object.keys(require.cache).forEach((key) => { delete require.cache[key] }) console.log(`${NETLIFYDEVLOG} ${path} ${action}, successfully reloaded!`) } const shouldBase64Encode = function (contentType) { return Boolean(contentType) && BASE_64_MIME_REGEXP.test(contentType) } const BASE_64_MIME_REGEXP = /image|audio|video|application\/pdf|application\/zip|applicaton\/octet-stream/i const validateFunctions = function ({ functions, capabilities, warn }) { if (!capabilities.backgroundFunctions && functions.some(({ isBackground }) => isBackground)) { warn(getLogMessage('functions.backgroundNotSupported')) } } 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')) const logger = winston.createLogger({ levels: winston.config.npm.levels, transports: [new winston.transports.Console({ level: 'warn' })], }) lambdaLocal.setLogger(logger) return function handler(request, response) { // handle proxies without path re-writes (http-servr) const cleanPath = request.path.replace(/^\/.netlify\/functions/, '') const functionName = cleanPath.split('/').find(Boolean) const func = functions.find(({ name }) => name === functionName) if (func === undefined) { response.statusCode = 404 response.end('Function not found...') return } const { mainFile: lambdaPath, isBackground } = func const isBase64Encoded = shouldBase64Encode(request.headers['content-type']) const body = request.get('content-length') ? request.body.toString(isBase64Encoded ? 'base64' : 'utf8') : undefined let remoteAddress = request.get('x-forwarded-for') || request.connection.remoteAddress || '' remoteAddress = remoteAddress .split(remoteAddress.includes('.') ? ':' : ',') .pop() .trim() let requestPath = request.path if (request.get('x-netlify-original-pathname')) { requestPath = request.get('x-netlify-original-pathname') delete request.headers['x-netlify-original-pathname'] } const queryParams = Object.entries(request.query).reduce( (prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }), {}, ) const headers = Object.entries({ ...request.headers, 'client-ip': [remoteAddress] }).reduce( (prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }), {}, ) const event = { path: requestPath, httpMethod: request.method, queryStringParameters: Object.entries(queryParams).reduce( (prev, [key, value]) => ({ ...prev, [key]: value.join(', ') }), {}, ), multiValueQueryStringParameters: queryParams, headers: Object.entries(headers).reduce((prev, [key, value]) => ({ ...prev, [key]: value.join(', ') }), {}), multiValueHeaders: headers, body, isBase64Encoded, } const clientContext = JSON.stringify(buildClientContext(request.headers) || {}) if (isBackground) { return executeBackgroundFunction({ event, lambdaPath, clientContext, response, functionName, }) } return executeSynchronousFunction({ event, lambdaPath, clientContext, response }) } } const createFormSubmissionHandler = function ({ siteUrl }) { return async function formSubmissionHandler(req, res, next) { if (req.url.startsWith('/.netlify/') || req.method !== 'POST') return next() const fakeRequest = new Readable({ read() { this.push(req.body) this.push(null) }, }) fakeRequest.headers = req.headers const originalUrl = new URL(req.url, 'http://localhost') req.url = `/.netlify/functions/submission-created${originalUrl.search}` const ct = parseContentType(req) let fields = {} let files = {} if (ct.type.endsWith('/x-www-form-urlencoded')) { const bodyData = await getRawBody(fakeRequest, { length: req.headers['content-length'], limit: '10mb', encoding: ct.parameters.charset, }) fields = querystring.parse(bodyData.toString()) } else if (ct.type === 'multipart/form-data') { try { ;[fields, files] = await new Promise((resolve, reject) => { const form = new multiparty.Form({ encoding: ct.parameters.charset || 'utf8' }) form.parse(fakeRequest, (err, Fields, Files) => { if (err) return reject(err) Files = Object.entries(Files).reduce( (prev, [name, values]) => ({ ...prev, [name]: values.map((value) => ({ filename: value.originalFilename, size: value.size, type: value.headers && value.headers['content-type'], url: value.path, })), }), {}, ) return resolve([ Object.entries(Fields).reduce( (prev, [name, values]) => ({ ...prev, [name]: values.length > 1 ? values : values[0] }), {}, ), Object.entries(Files).reduce( (prev, [name, values]) => ({ ...prev, [name]: values.length > 1 ? values : values[0] }), {}, ), ]) }) }) } catch (error) { return console.error(error) } } else { return console.error('Invalid Content-Type for Netlify Dev forms request') } const data = JSON.stringify({ payload: { company: fields[Object.keys(fields).find((name) => ['company', 'business', 'employer'].includes(name.toLowerCase()))], last_name: fields[Object.keys(fields).find((name) => ['lastname', 'surname', 'byname'].includes(name.toLowerCase()))], first_name: fields[ Object.keys(fields).find((name) => ['firstname', 'givenname', 'forename'].includes(name.toLowerCase())) ], name: fields[Object.keys(fields).find((name) => ['name', 'fullname'].includes(name.toLowerCase()))], email: fields[ Object.keys(fields).find((name) => ['email', 'mail', 'from', 'twitter', 'sender'].includes(name.toLowerCase()), ) ], title: fields[Object.keys(fields).find((name) => ['title', 'subject'].includes(name.toLowerCase()))], data: { ...fields, ...files, ip: req.connection.remoteAddress, user_agent: req.headers['user-agent'], referrer: req.headers.referer, }, created_at: new Date().toISOString(), human_fields: Object.entries({ ...fields, ...Object.entries(files).reduce((prev, [name, { url }]) => ({ ...prev, [name]: url }), {}), }).reduce((prev, [key, val]) => ({ ...prev, [capitalize(key)]: val }), {}), ordered_human_fields: Object.entries({ ...fields, ...Object.entries(files).reduce((prev, [name, { url }]) => ({ ...prev, [name]: url }), {}), }).map(([key, val]) => ({ title: capitalize(key), name: key, value: val })), site_url: siteUrl, }, }) req.body = data req.headers = { ...req.headers, 'content-length': data.length, 'content-type': 'application/json', 'x-netlify-original-pathname': originalUrl.pathname, } next() } } const getFunctionsServer = async function ({ dir, siteUrl, capabilities, warn }) { const app = express() app.set('query parser', 'simple') app.use( bodyParser.text({ limit: '6mb', type: ['text/*', 'application/json'], }), ) app.use(bodyParser.raw({ limit: '6mb', type: '*/*' })) app.use(createFormSubmissionHandler({ siteUrl })) app.use( expressLogging(console, { blacklist: ['/favicon.ico'], }), ) app.get('/favicon.ico', function onRequest(req, res) { res.status(204).end() }) app.all('*', await createHandler({ dir, capabilities, warn })) return app } const getBuildFunction = ({ functionBuilder, log }) => { return async function build() { log( `${NETLIFYDEVLOG} Function builder ${chalk.yellow(functionBuilder.builderName)} ${chalk.magenta( 'building', )} functions from directory ${chalk.yellow(functionBuilder.src)}`, ) try { await functionBuilder.build() log( `${NETLIFYDEVLOG} Function builder ${chalk.yellow(functionBuilder.builderName)} ${chalk.green( 'finished', )} building functions from directory ${chalk.yellow(functionBuilder.src)}`, ) } catch (error) { const errorMessage = (error.stderr && error.stderr.toString()) || error.message log( `${NETLIFYDEVLOG} Function builder ${chalk.yellow(functionBuilder.builderName)} ${chalk.red( 'failed', )} building functions from directory ${chalk.yellow(functionBuilder.src)}${ errorMessage ? ` with error:\n${errorMessage}` : '' }`, ) } } } 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/`, ) const debouncedBuild = debounce(getBuildFunction({ functionBuilder, log }), 300, { leading: true, trailing: true, }) await debouncedBuild() const functionWatcher = chokidar.watch(functionBuilder.src) functionWatcher.on('ready', () => { functionWatcher.on('add', debouncedBuild) functionWatcher.on('change', debouncedBuild) functionWatcher.on('unlink', debouncedBuild) }) } } 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 }) } } module.exports = { startFunctionsServer }