From 2c6a57a8e435e3304cb45e93fb7158baf3d924a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 11 Jul 2021 12:00:17 +0100 Subject: [PATCH] fix(command-dev): invoke functions from runtime (#2826) --- .github/workflows/main.yml | 2 + src/lib/functions/background.js | 29 +-- src/lib/functions/builder.js | 137 ---------- src/lib/functions/form-submissions-handler.js | 8 +- src/lib/functions/memoized-build.js | 4 +- src/lib/functions/netlify-function.js | 98 ++++++++ src/lib/functions/registry.js | 212 ++++++++++++++++ src/lib/functions/runtimes/index.js | 37 +++ .../runtimes/js/builders/netlify-lambda.js | 33 +-- .../js/builders/tests/netlify-lambda.test.js | 62 ++--- .../functions/runtimes/js/builders/zisi.js | 238 +++--------------- src/lib/functions/runtimes/js/index.js | 79 ++++++ src/lib/functions/server.js | 83 +++--- src/lib/functions/synchronous.js | 64 ++--- src/lib/functions/utils.js | 18 -- src/lib/functions/watcher.js | 60 ++--- src/utils/difference.js | 4 + tests/serving-functions.test.js | 2 +- 18 files changed, 600 insertions(+), 570 deletions(-) delete mode 100644 src/lib/functions/builder.js create mode 100644 src/lib/functions/netlify-function.js create mode 100644 src/lib/functions/registry.js create mode 100644 src/lib/functions/runtimes/index.js create mode 100644 src/lib/functions/runtimes/js/index.js create mode 100644 src/utils/difference.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d48340fbfa7..f09240e7217 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -61,6 +61,8 @@ jobs: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} # NETLIFY_TEST_GITHUB_TOKEN is used to avoid reaching GitHub API limits in exec-fetcher.js NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Changes the polling interval used by the file watcher + CHOKIDAR_INTERVAL: 20 - name: Get test coverage flags id: test-coverage-flags run: |- diff --git a/src/lib/functions/background.js b/src/lib/functions/background.js index 70959a151f8..2f00bd156af 100644 --- a/src/lib/functions/background.js +++ b/src/lib/functions/background.js @@ -1,12 +1,16 @@ -const lambdaLocal = require('lambda-local') - const { NETLIFYDEVERR, NETLIFYDEVLOG } = require('../../utils/logo') -const { DEFAULT_LAMBDA_OPTIONS, formatLambdaError, SECONDS_TO_MILLISECONDS, styleFunctionName } = require('./utils') +const { formatLambdaError, styleFunctionName } = require('./utils') const BACKGROUND_FUNCTION_STATUS_CODE = 202 -const createBackgroundFunctionCallback = (functionName) => (err) => { +const handleBackgroundFunction = (functionName, response) => { + console.log(`${NETLIFYDEVLOG} Queueing background function ${styleFunctionName(functionName)} for execution`) + response.status(BACKGROUND_FUNCTION_STATUS_CODE) + response.end() +} + +const handleBackgroundFunctionResult = (functionName, err) => { if (err) { console.log( `${NETLIFYDEVERR} Error during background function ${styleFunctionName(functionName)} execution:`, @@ -17,19 +21,4 @@ const createBackgroundFunctionCallback = (functionName) => (err) => { } } -const executeBackgroundFunction = ({ event, lambdaPath, timeout, 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: timeout * SECONDS_TO_MILLISECONDS, - }) -} - -module.exports = { executeBackgroundFunction } +module.exports = { handleBackgroundFunction, handleBackgroundFunctionResult } diff --git a/src/lib/functions/builder.js b/src/lib/functions/builder.js deleted file mode 100644 index 310f0255ea2..00000000000 --- a/src/lib/functions/builder.js +++ /dev/null @@ -1,137 +0,0 @@ -const { relative } = require('path') -const { cwd } = require('process') - -const chalk = require('chalk') -const chokidar = require('chokidar') -const decache = require('decache') -const pEvent = require('p-event') - -const { detectFunctionsBuilder } = require('../../utils/detect-functions-builder') -const { getFunctionsAndWatchDirs } = require('../../utils/get-functions') -const { NETLIFYDEVLOG } = require('../../utils/logo') - -const { logBeforeAction, logAfterAction, validateFunctions } = require('./utils') -const { watchDebounced } = require('./watcher') - -const getBuildFunction = ({ functionBuilder, log }) => - async function build(updatedPath, eventType) { - const relativeFunctionsDir = relative(cwd(), functionBuilder.src) - - log(`${NETLIFYDEVLOG} ${chalk.magenta('Building')} functions from directory ${chalk.yellow(relativeFunctionsDir)}`) - - try { - const functions = await functionBuilder.build(updatedPath, eventType) - const functionNames = (functions || []).map((path) => relative(functionBuilder.src, path)) - - // If the build command has returned a set of functions that have been - // updated, the list them in the log message. If not, we show a generic - // message with the functions directory. - if (functionNames.length === 0) { - log( - `${NETLIFYDEVLOG} ${chalk.green('Finished')} building functions from directory ${chalk.yellow( - relativeFunctionsDir, - )}`, - ) - } else { - log( - `${NETLIFYDEVLOG} ${chalk.green('Finished')} building functions: ${functionNames - .map((name) => chalk.yellow(name)) - .join(', ')}`, - ) - } - } catch (error) { - const errorMessage = (error.stderr && error.stderr.toString()) || error.message - log( - `${NETLIFYDEVLOG} ${chalk.red('Failed')} building functions from directory ${chalk.yellow( - relativeFunctionsDir, - )}${errorMessage ? ` with error:\n${errorMessage}` : ''}`, - ) - } - } - -const setupDefaultFunctionHandler = async ({ capabilities, directory, warn }) => { - const context = { - functions: [], - watchDirs: [], - } - const { functions, watchDirs } = await getFunctionsAndWatchDirs(directory) - const onAdd = async (path) => { - logBeforeAction({ path, action: 'added' }) - - if (context.watchDirs.length !== 0) { - await watcher.unwatch(watchDirs) - } - - const { functions: newFunctions, watchDirs: newWatchDirs } = await getFunctionsAndWatchDirs(directory) - - validateFunctions({ functions, capabilities, warn }) - - await watcher.add(newWatchDirs) - - context.functions = newFunctions - context.watchDirs = newWatchDirs - - logAfterAction({ path, action: 'added' }) - } - // Keeping the function here for clarity. This part of the code will be - // refactored soon anyway. - // eslint-disable-next-line unicorn/consistent-function-scoping - const onChange = (path) => { - logBeforeAction({ path, action: 'modified' }) - logAfterAction({ path, action: 'modified' }) - } - const onUnlink = (path) => { - logBeforeAction({ path, action: 'deleted' }) - - context.functions = context.functions.filter((func) => func.mainFile !== path) - - logAfterAction({ path, action: 'deleted' }) - } - const watcher = await watchDebounced(watchDirs, { onAdd, onChange, onUnlink }) - - validateFunctions({ functions, capabilities, warn }) - - context.functions = functions - context.watchDirs = watchDirs - - const getFunctionByName = (functionName) => context.functions.find(({ name }) => name === functionName) - - return { getFunctionByName } -} - -const setupFunctionsBuilder = async ({ config, errorExit, functionsDirectory, log, site }) => { - const functionBuilder = await detectFunctionsBuilder({ - config, - errorExit, - functionsDirectory, - log, - projectRoot: site.root, - }) - - if (!functionBuilder) { - return {} - } - - const npmScriptString = functionBuilder.npmScript - ? `: Running npm script ${chalk.yellow(functionBuilder.npmScript)}` - : '' - - log(`${NETLIFYDEVLOG} Function builder ${chalk.yellow(functionBuilder.builderName)} detected${npmScriptString}.`) - - const buildFunction = getBuildFunction({ functionBuilder, log }) - - await buildFunction() - - const functionWatcher = chokidar.watch(functionBuilder.src) - await pEvent(functionWatcher, 'ready') - functionWatcher.on('add', (path) => buildFunction(path, 'add')) - functionWatcher.on('change', async (path) => { - await buildFunction(path, 'change') - decache(path) - }) - functionWatcher.on('unlink', (path) => buildFunction(path, 'unlink')) - - return functionBuilder -} - -module.exports = { setupDefaultFunctionHandler, setupFunctionsBuilder } diff --git a/src/lib/functions/form-submissions-handler.js b/src/lib/functions/form-submissions-handler.js index 811f4e737c3..0ef477a3b61 100644 --- a/src/lib/functions/form-submissions-handler.js +++ b/src/lib/functions/form-submissions-handler.js @@ -8,7 +8,7 @@ const getRawBody = require('raw-body') const { BACKGROUND } = require('../../utils/get-functions') const { capitalize } = require('../string') -const createFormSubmissionHandler = function ({ getFunctionByName, siteUrl, warn }) { +const createFormSubmissionHandler = function ({ functionsRegistry, siteUrl, warn }) { return async function formSubmissionHandler(req, res, next) { if (req.url.startsWith('/.netlify/') || req.method !== 'POST') return next() @@ -20,7 +20,7 @@ const createFormSubmissionHandler = function ({ getFunctionByName, siteUrl, warn }) fakeRequest.headers = req.headers - const handlerName = getFormHandler({ getFunctionByName, warn }) + const handlerName = getFormHandler({ functionsRegistry, warn }) if (!handlerName) { return next() } @@ -125,9 +125,9 @@ const createFormSubmissionHandler = function ({ getFunctionByName, siteUrl, warn } } -const getFormHandler = function ({ getFunctionByName, warn }) { +const getFormHandler = function ({ functionsRegistry, warn }) { const handlers = ['submission-created', `submission-created${BACKGROUND}`] - .map((name) => getFunctionByName(name)) + .map((name) => functionsRegistry.get(name)) .filter(Boolean) .map(({ name }) => name) diff --git a/src/lib/functions/memoized-build.js b/src/lib/functions/memoized-build.js index 6e0f5aeefcd..98c6930c90e 100644 --- a/src/lib/functions/memoized-build.js +++ b/src/lib/functions/memoized-build.js @@ -1,7 +1,5 @@ const DEBOUNCE_INTERVAL = 300 -const cache = {} - // `memoizedBuild` will avoid running the same build command multiple times // until the previous operation has been completed. If another call is made // within that period, it will be: @@ -10,7 +8,7 @@ const cache = {} // This allows us to discard any duplicate filesystem events, while ensuring // that actual updates happening during the zip operation will be executed // after it finishes (only the last update will run). -const memoizedBuild = ({ cacheKey, command }) => { +const memoizedBuild = ({ cache, cacheKey, command }) => { if (cache[cacheKey] === undefined) { cache[cacheKey] = { // eslint-disable-next-line promise/prefer-await-to-then diff --git a/src/lib/functions/netlify-function.js b/src/lib/functions/netlify-function.js new file mode 100644 index 00000000000..06a22420bfd --- /dev/null +++ b/src/lib/functions/netlify-function.js @@ -0,0 +1,98 @@ +const { difference } = require('../../utils/difference') + +const BACKGROUND_SUFFIX = '-background' + +class NetlifyFunction { + constructor({ + config, + errorExit, + functionsDirectory, + mainFile, + name, + projectRoot, + runtime, + timeoutBackground, + timeoutSynchronous, + }) { + this.config = config + this.errorExit = errorExit + this.functionsDirectory = functionsDirectory + this.mainFile = mainFile + this.name = name + this.projectRoot = projectRoot + this.runtime = runtime + this.timeoutBackground = timeoutBackground + this.timeoutSynchronous = timeoutSynchronous + + // Determines whether this is a background function based on the function + // name. + this.isBackground = name.endsWith(BACKGROUND_SUFFIX) + + // List of the function's source files. This starts out as an empty set + // and will get populated on every build. + this.srcFiles = new Set() + } + + // The `build` method transforms source files into invocable functions. Its + // return value is an object with: + // + // - `srcFilesDiff`: Files that were added and removed since the last time + // the function was built. + async build({ cache }) { + const buildFunction = await this.runtime.getBuildFunction({ + config: this.config, + errorExit: this.errorExit, + func: this, + functionsDirectory: this.functionsDirectory, + projectRoot: this.projectRoot, + }) + + this.buildQueue = buildFunction({ cache }) + + try { + const { srcFiles, ...buildData } = await this.buildQueue + const srcFilesSet = new Set(srcFiles) + const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet) + + this.buildData = buildData + this.srcFiles = srcFilesSet + + return { srcFilesDiff } + } catch (error) { + return { error } + } + } + + // Compares a new set of source files against a previous one, returning an + // object with two Sets, one with added and the other with deleted files. + getSrcFilesDiff(newSrcFiles) { + const added = difference(newSrcFiles, this.srcFiles) + const deleted = difference(this.srcFiles, newSrcFiles) + + return { + added, + deleted, + } + } + + // Invokes the function and returns its response object. + async invoke(event, context) { + await this.buildQueue + + const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous + + try { + const result = await this.runtime.invokeFunction({ + context, + event, + func: this, + timeout, + }) + return { result, error: null } + } catch (error) { + return { result: null, error } + } + } +} + +module.exports = { NetlifyFunction } diff --git a/src/lib/functions/registry.js b/src/lib/functions/registry.js new file mode 100644 index 00000000000..fd3f48990a2 --- /dev/null +++ b/src/lib/functions/registry.js @@ -0,0 +1,212 @@ +const chalk = require('chalk') + +const { NETLIFYDEVLOG, NETLIFYDEVERR } = require('../../utils/logo') +const { mkdirRecursiveAsync } = require('../fs') +const { getLogMessage } = require('../log') + +const { NetlifyFunction } = require('./netlify-function') +const runtimes = require('./runtimes') +const { watchDebounced } = require('./watcher') + +class FunctionsRegistry { + constructor({ capabilities, config, errorExit, functionsDirectory, log, projectRoot, timeouts, warn }) { + this.capabilities = capabilities + this.config = config + this.errorExit = errorExit + this.functionsDirectory = functionsDirectory + this.logger = { + log, + warn, + } + this.projectRoot = projectRoot + this.timeouts = timeouts + + // An object to be shared among all functions in the registry. It can be + // used to cache the results of the build function — e.g. it's used in + // the `memoizedBuild` method in the JavaScript runtime. + this.buildCache = {} + + // File watchers for parent directories where functions live — i.e. the + // ones supplied to `scan()`. This is a Map because in the future we + // might have several function directories. + this.directoryWatchers = new Map() + + // The functions held by the registry. Maps function names to instances of + // `NetlifyFunction`. + this.functions = new Map() + + // File watchers for function files. Maps function names to objects built + // by the `watchDebounced` utility. + this.functionWatchers = new Map() + + // Performance optimization: load '@netlify/zip-it-and-ship-it' on demand. + // eslint-disable-next-line node/global-require + const { listFunctions } = require('@netlify/zip-it-and-ship-it') + + this.listFunctions = listFunctions + } + + async buildFunctionAndWatchFiles(func, { verbose } = {}) { + if (verbose) { + this.logger.log(`${NETLIFYDEVLOG} ${chalk.magenta('Reloading')} function ${chalk.yellow(func.name)}...`) + } + + const { error, srcFilesDiff } = await func.build({ cache: this.buildCache }) + + if (error) { + this.logger.log( + `${NETLIFYDEVERR} ${chalk.red('Failed')} reloading function ${chalk.yellow(func.name)} with error:\n${ + error.message + }`, + ) + } else if (verbose) { + this.logger.log(`${NETLIFYDEVLOG} ${chalk.green('Reloaded')} function ${chalk.yellow(func.name)}`) + } + + // If the build hasn't resulted in any files being added or removed, there + // is nothing else we need to do. + if (!srcFilesDiff) { + return + } + + const watcher = this.functionWatchers.get(func.name) + + // If there is already a watcher for this function, we need to unwatch any + // files that have been removed and watch any files that have been added. + if (watcher) { + srcFilesDiff.deleted.forEach((path) => { + watcher.unwatch(path) + }) + + srcFilesDiff.added.forEach((path) => { + watcher.add(path) + }) + + return + } + + // If there is no watcher for this function but the build produced files, + // we create a new watcher and watch them. + if (srcFilesDiff.added.size !== 0) { + const newWatcher = await watchDebounced([...srcFilesDiff.added], { + onChange: () => { + this.buildFunctionAndWatchFiles(func, { verbose: true }) + }, + }) + + this.functionWatchers.set(func.name, newWatcher) + } + } + + get(name) { + return this.functions.get(name) + } + + registerFunction(name, func) { + if (func.isBackground && !this.capabilities.backgroundFunctions) { + this.logger.warn(getLogMessage('functions.backgroundNotSupported')) + } + + this.functions.set(name, func) + this.buildFunctionAndWatchFiles(func) + + this.logger.log(`${NETLIFYDEVLOG} ${chalk.green('Loaded')} function ${chalk.yellow(name)}.`) + } + + async scan(directory) { + await mkdirRecursiveAsync(directory) + + // We give runtimes the opportunity to react to a directory scan and run + // additional logic before the directory is read. So if they implement a + // `onDirectoryScan` hook, we run it. + await Promise.all( + Object.values(runtimes).map((runtime) => { + if (typeof runtime.onDirectoryScan !== 'function') { + return null + } + + return runtime.onDirectoryScan({ directory }) + }), + ) + + const functions = await this.listFunctions(directory) + + // Before registering any functions, we look for any functions that were on + // the previous list but are missing from the new one. We unregister them. + const deletedFunctions = [...this.functions.values()].filter((oldFunc) => { + const isFound = functions.some( + (newFunc) => newFunc.name === oldFunc.name && newFunc.runtime === oldFunc.runtime.name, + ) + + return !isFound + }) + + await Promise.all(deletedFunctions.map((func) => this.unregisterFunction(func.name))) + + functions.forEach(({ mainFile, name, runtime: runtimeName }) => { + const runtime = runtimes[runtimeName] + + // If there is no matching runtime, it means this function is not yet + // supported in Netlify Dev. + if (runtime === undefined) { + return + } + + // If this function has already been registered, we skip it. + if (this.functions.has(name)) { + return + } + + const func = new NetlifyFunction({ + config: this.config, + errorExit: this.errorExit, + functionsDirectory: this.functionsDirectory, + mainFile, + name, + projectRoot: this.projectRoot, + runtime, + timeoutBackground: this.timeouts.backgroundFunctions, + timeoutSynchronous: this.timeouts.syncFunctions, + }) + + this.registerFunction(name, func) + }) + + await this.setupDirectoryWatcher(directory) + } + + // This watcher looks at files being added or removed from a functions + // directory. It doesn't care about files being changed, because those + // will be handled by each functions' watcher. + async setupDirectoryWatcher(directory) { + if (this.directoryWatchers.has(directory)) { + return + } + + const watcher = await watchDebounced(directory, { + depth: 1, + onAdd: () => { + this.scan(directory) + }, + onUnlink: () => { + this.scan(directory) + }, + }) + + this.directoryWatchers.set(directory, watcher) + } + + async unregisterFunction(name) { + this.functions.delete(name) + + this.logger.log(`${NETLIFYDEVLOG} ${chalk.magenta('Removed')} function ${chalk.yellow(name)}.`) + + const watcher = this.functionWatchers.get(name) + + if (watcher) { + await watcher.close() + } + } +} + +module.exports = { FunctionsRegistry } diff --git a/src/lib/functions/runtimes/index.js b/src/lib/functions/runtimes/index.js new file mode 100644 index 00000000000..3b718f43217 --- /dev/null +++ b/src/lib/functions/runtimes/index.js @@ -0,0 +1,37 @@ +const js = require('./js') + +/** + * @callback BuildFunction + * @param {object} func + * @returns {Promise<{srcFiles: string[], buildPath?: string>} + */ + +/** + * @callback GetBuildFunction + * @param {{ config: object, context: object, errorExit: function, func: object, functionsDirectory: string, projectRoot: string }} params + * @returns {Promise} + */ + +/** + * @callback InvokeFunction + * @param {{ context: object, event: object, func: object, timeout: number }} params + * @returns {Promise<{ body: object, statusCode: number }>} + */ + +/** + * @callback OnDirectoryScanFunction + * @param {{ directory: string }} params + * @returns {Promise} + */ + +/** + * @typedef {object} Runtime + * @property {GetBuildFunction} getBuildFunction + * @property {InvokeFunction} invokeFunction + * @property {OnDirectoryScanFunction} [onDirectoryScan] + * @property {string} name + */ + +const runtimes = [js].reduce((res, runtime) => ({ ...res, [runtime.name]: runtime }), {}) + +module.exports = runtimes diff --git a/src/lib/functions/runtimes/js/builders/netlify-lambda.js b/src/lib/functions/runtimes/js/builders/netlify-lambda.js index f4889751846..cba9101b2ea 100644 --- a/src/lib/functions/runtimes/js/builders/netlify-lambda.js +++ b/src/lib/functions/runtimes/js/builders/netlify-lambda.js @@ -1,12 +1,13 @@ +const { resolve } = require('path') + const execa = require('execa') -const debounce = require('lodash/debounce') const minimist = require('minimist') const { fileExistsAsync, readFileAsync } = require('../../../../fs') +const { memoizedBuild } = require('../../../memoized-build') -const DEBOUNCE_WAIT = 300 - -const detectNetlifyLambda = async function ({ dependencies, devDependencies, scripts } = {}) { +const detectNetlifyLambda = async function ({ packageJson } = {}) { + const { dependencies, devDependencies, scripts } = packageJson || {} if (!((dependencies && dependencies['netlify-lambda']) || (devDependencies && devDependencies['netlify-lambda']))) { return false } @@ -19,20 +20,24 @@ const detectNetlifyLambda = async function ({ dependencies, devDependencies, scr // We are not interested in 'netlify-lambda' and 'build' commands const functionDirectories = match._.slice(2) if (functionDirectories.length === 1) { + const srcFiles = [resolve(functionDirectories[0])] + // eslint-disable-next-line no-await-in-loop const yarnExists = await fileExistsAsync('yarn.lock') - const debouncedBuild = debounce(execa, DEBOUNCE_WAIT, { - leading: false, - trailing: true, - }) + const buildCommand = () => execa(yarnExists ? 'yarn' : 'npm', ['run', key]) return { - src: functionDirectories[0], - npmScript: key, - build: async () => { - await debouncedBuild(yarnExists ? 'yarn' : 'npm', ['run', key]) + build: async ({ cache = {} } = {}) => { + await memoizedBuild({ cache, cacheKey: `netlify-lambda-${key}`, command: buildCommand }) + + return { + srcFiles, + } }, builderName: 'netlify-lambda', + + // Currently used for tests only. + npmScript: key, } } if (functionDirectories.length === 0) { @@ -54,7 +59,7 @@ module.exports = async function handler() { } const content = await readFileAsync('package.json') - const packageSettings = JSON.parse(content, { encoding: 'utf8' }) - return detectNetlifyLambda(packageSettings) + const packageJson = JSON.parse(content, { encoding: 'utf8' }) + return detectNetlifyLambda({ packageJson }) } module.exports.detectNetlifyLambda = detectNetlifyLambda diff --git a/src/lib/functions/runtimes/js/builders/tests/netlify-lambda.test.js b/src/lib/functions/runtimes/js/builders/tests/netlify-lambda.test.js index a97dd90ea9a..45ac9634d58 100644 --- a/src/lib/functions/runtimes/js/builders/tests/netlify-lambda.test.js +++ b/src/lib/functions/runtimes/js/builders/tests/netlify-lambda.test.js @@ -12,7 +12,7 @@ test(`should not match if netlify-lambda is missing from dependencies`, async (t dependencies: {}, devDependencies: {}, } - t.is(await detectNetlifyLambda(packageJson), false) + t.is(await detectNetlifyLambda({ packageJson }), false) }) test.serial('should not match if netlify-lambda is missing functions directory', async (t) => { @@ -30,7 +30,7 @@ test.serial('should not match if netlify-lambda is missing functions directory', const spyConsoleWarn = sandbox.spy(console, 'warn') - t.is(await detectNetlifyLambda(packageJson), false) + t.is(await detectNetlifyLambda({ packageJson }), false) // Not checking for exact warning string as it would make this test too specific/brittle t.is(spyConsoleWarn.calledWithMatch('contained no functions folder'), true) @@ -53,7 +53,7 @@ test.serial('should not match if netlify-lambda contains multiple function direc const spyConsoleWarn = sandbox.spy(console, 'warn') - t.is(await detectNetlifyLambda(packageJson), false) + t.is(await detectNetlifyLambda({ packageJson }), false) // Not checking for exact warning string as it would make this test too specific/brittle t.is(spyConsoleWarn.calledWithMatch('contained 2 or more function folders'), true) @@ -64,7 +64,7 @@ test.serial('should not match if netlify-lambda contains multiple function direc test(`should match if netlify-lambda is listed in dependencies and is mentioned in scripts`, async (t) => { const packageJson = { scripts: { - 'some-build-step': 'netlify-lambda build some/directory', + build: 'netlify-lambda build some/directory', }, dependencies: { 'netlify-lambda': 'ignored', @@ -72,18 +72,16 @@ test(`should match if netlify-lambda is listed in dependencies and is mentioned devDependencies: {}, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) + const match = await detectNetlifyLambda({ packageJson }) - t.is(match.src, 'some/directory') t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) test(`should match if netlify-lambda is listed in devDependencies and is mentioned in scripts`, async (t) => { const packageJson = { scripts: { - 'some-build-step': 'netlify-lambda build some/directory', + build: 'netlify-lambda build some/directory', }, dependencies: {}, devDependencies: { @@ -91,12 +89,10 @@ test(`should match if netlify-lambda is listed in devDependencies and is mention }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) + const match = await detectNetlifyLambda({ packageJson }) - t.is(match.src, 'some/directory') t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) test(`should not match if netlify-lambda misses function directory`, async (t) => { @@ -110,14 +106,14 @@ test(`should not match if netlify-lambda misses function directory`, async (t) = }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, true) + const match = await detectNetlifyLambda({ packageJson }) + t.is(match, false) }) test(`should match if netlify-lambda is configured with an additional option`, async (t) => { const packageJson = { scripts: { - 'some-build-step': 'netlify-lambda build --config config/webpack.config.js some/directory', + build: 'netlify-lambda build --config config/webpack.config.js some/directory', }, dependencies: {}, devDependencies: { @@ -125,18 +121,15 @@ test(`should match if netlify-lambda is configured with an additional option`, a }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) - - t.is(match.src, 'some/directory') + const match = await detectNetlifyLambda({ packageJson }) t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) test(`should match if netlify-lambda is configured with multiple additional options`, async (t) => { const packageJson = { scripts: { - 'some-build-step': 'netlify-lambda build -s --another-option --config config/webpack.config.js some/directory', + build: 'netlify-lambda build -s --another-option --config config/webpack.config.js some/directory', }, dependencies: {}, devDependencies: { @@ -144,18 +137,15 @@ test(`should match if netlify-lambda is configured with multiple additional opti }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) - - t.is(match.src, 'some/directory') + const match = await detectNetlifyLambda({ packageJson }) t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) test('should match if netlify-lambda has options that are passed after the functions directory', async (t) => { const packageJson = { scripts: { - 'some-build-step': 'netlify-lambda build some/directory --config config/webpack.config.js', + build: 'netlify-lambda build some/directory --config config/webpack.config.js', }, dependencies: {}, devDependencies: { @@ -163,19 +153,16 @@ test('should match if netlify-lambda has options that are passed after the funct }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) - - t.is(match.src, 'some/directory') + const match = await detectNetlifyLambda({ packageJson }) t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) test('should match even if multiple netlify-lambda commands are specified', async (t) => { const packageJson = { scripts: { 'some-serve-step': 'netlify-lambda serve serve/directory', - 'some-build-step': 'netlify-lambda build build/directory', + build: 'netlify-lambda build build/directory', }, dependencies: {}, devDependencies: { @@ -183,10 +170,7 @@ test('should match even if multiple netlify-lambda commands are specified', asyn }, } - const match = await detectNetlifyLambda(packageJson) - t.not(match, false) - - t.is(match.src, 'build/directory') + const match = await detectNetlifyLambda({ packageJson }) t.is(match.builderName, 'netlify-lambda') - t.is(match.npmScript, 'some-build-step') + t.is(match.npmScript, 'build') }) diff --git a/src/lib/functions/runtimes/js/builders/zisi.js b/src/lib/functions/runtimes/js/builders/zisi.js index 7a42e4fb4f4..56a0bdaf697 100644 --- a/src/lib/functions/runtimes/js/builders/zisi.js +++ b/src/lib/functions/runtimes/js/builders/zisi.js @@ -1,19 +1,15 @@ const path = require('path') -const { zipFunction, zipFunctions } = require('@netlify/zip-it-and-ship-it') -const del = require('del') +const { zipFunction } = require('@netlify/zip-it-and-ship-it') +const decache = require('decache') const makeDir = require('make-dir') -const pFilter = require('p-filter') const sourceMapSupport = require('source-map-support') -const { getFunctions } = require('../../../../../utils/get-functions') const { NETLIFYDEVERR } = require('../../../../../utils/logo') const { getPathInProject } = require('../../../../settings') const { normalizeFunctionsConfig } = require('../../../config') const { memoizedBuild } = require('../../../memoized-build') -const ZIP_CONCURRENCY = 5 - const addFunctionsConfigDefaults = (config) => ({ ...config, '*': { @@ -22,189 +18,42 @@ const addFunctionsConfigDefaults = (config) => ({ }, }) -const addFunctionToTree = (func, fileTree) => { - // Transforming the inputs into a Set so that we can have a O(1) lookup. - const inputs = new Set(func.inputs) - - // The `mainFile` property returned from ZISI will point to the original main - // function file, but we want to serve the bundled version, which we set as - // the `bundleFile` property. - const bundleFile = path.join(func.path, `${func.name}.js`) - - fileTree.set(func.mainFile, { ...func, bundleFile, inputs }) -} - -const clearFunctionsCache = (functionsPath) => { - Object.keys(require.cache) - .filter((key) => key.startsWith(functionsPath) && !functionsPath.includes(`${path.sep}node_modules${path.sep}`)) - .forEach((requirePath) => { - delete require.cache[requirePath] - }) -} - -const zipFunctionsAndUpdateTree = async ({ fileTree, functions, sourceDirectory, targetDirectory, zipOptions }) => { - if (functions !== undefined) { - await pFilter( - functions, - async ({ mainFile }) => { - const functionDirectory = path.dirname(mainFile) - - // If we have a function at `functions/my-func/index.js` and we pass - // that path to `zipFunction`, it will lack the context of the whole - // functions directory and will infer the name of the function to be - // `index`, not `my-func`. Instead, we need to pass the directory of - // the function. The exception is when the function is a file at the - // root of the functions directory (e.g. `functions/my-func.js`). In - // this case, we use `mainFile` as the function path of `zipFunction`. - const entryPath = functionDirectory === sourceDirectory ? mainFile : functionDirectory - const func = await memoizedBuild({ - cacheKey: `zisi-${entryPath}`, - command: () => zipFunction(entryPath, targetDirectory, zipOptions), - }) - - addFunctionToTree(func, fileTree) - }, - { concurrency: ZIP_CONCURRENCY }, - ) - - return - } - - const result = await memoizedBuild({ - cacheKey: 'zisi@all', - command: () => zipFunctions(sourceDirectory, targetDirectory, zipOptions), - }) - - result.forEach((func) => { - addFunctionToTree(func, fileTree) - }) -} - -// Bundles a set of functions based on the event type and the updated path. It -// returns an array with the paths of the functions that were rebundled as a -// result of the update event. -const bundleFunctions = async ({ - config, - eventType, - fileTree, - projectRoot, - sourceDirectory, - targetDirectory, - updatedPath, - zipCache, -}) => { +const buildFunction = async ({ cache, config, func, functionsDirectory, projectRoot, targetDirectory }) => { const zipOptions = { archiveFormat: 'none', basePath: projectRoot, config, } + const functionDirectory = path.dirname(func.mainFile) + + // If we have a function at `functions/my-func/index.js` and we pass + // that path to `zipFunction`, it will lack the context of the whole + // functions directory and will infer the name of the function to be + // `index`, not `my-func`. Instead, we need to pass the directory of + // the function. The exception is when the function is a file at the + // root of the functions directory (e.g. `functions/my-func.js`). In + // this case, we use `mainFile` as the function path of `zipFunction`. + const entryPath = functionDirectory === functionsDirectory ? func.mainFile : functionDirectory + const { inputs, path: functionPath } = await memoizedBuild({ + cache, + cacheKey: `zisi-${entryPath}`, + command: () => zipFunction(entryPath, targetDirectory, zipOptions), + }) + const srcFiles = inputs.filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`)) + const buildPath = path.join(functionPath, `${func.name}.js`) clearFunctionsCache(targetDirectory) - if (eventType === 'add') { - // We first check to see if the file being added is associated with any - // functions (e.g. restoring a file that has been previously deleted). - // If that's the case, we bundle just those functions. - const functionsWithPath = [...fileTree.entries()].filter(([, { inputs }]) => inputs.has(updatedPath)) - - if (functionsWithPath.length !== 0) { - await zipFunctionsAndUpdateTree({ - fileTree, - functions: functionsWithPath.map(([, func]) => func), - sourceDirectory, - targetDirectory, - zipCache, - zipOptions, - }) - - return functionsWithPath.map(([mainFile]) => mainFile) - } - - // We then check whether the newly-added file is itself a function. If so, - // we bundle it. - const functions = await getFunctions(sourceDirectory) - const matchingFunction = functions.find(({ mainFile }) => mainFile === updatedPath) - - if (matchingFunction !== undefined) { - await zipFunctionsAndUpdateTree({ - fileTree, - functions: [matchingFunction], - sourceDirectory, - targetDirectory, - zipCache, - zipOptions, - }) - - return [updatedPath] - } - - // At this point, the newly-added file is neither a function nor a file - // associated with a function, so we can discard the update. - return - } - - if (eventType === 'change' || eventType === 'unlink') { - // If the file matches a function's main file, we just need to operate on - // that one function. - if (fileTree.has(updatedPath)) { - const matchingFunction = fileTree.get(updatedPath) - - // We bundle the function if this is a `change` event, or delete it if - // the event is `unlink`. - if (eventType === 'change') { - await zipFunctionsAndUpdateTree({ - fileTree, - functions: [matchingFunction], - sourceDirectory, - targetDirectory, - zipCache, - zipOptions, - }) - } else { - const { path: functionPath } = matchingFunction - - fileTree.delete(updatedPath) - - await del(functionPath, { force: true }) - } - - return [updatedPath] - } - - // The update is in one of the supporting files. We bundle every function - // that uses it. - const functions = [...fileTree.entries()].filter(([, { inputs }]) => inputs.has(updatedPath)) - - await zipFunctionsAndUpdateTree({ - fileTree, - functions: functions.map(([, func]) => func), - sourceDirectory, - targetDirectory, - zipCache, - zipOptions, - }) - - return functions.map(([mainFile]) => mainFile) - } - - // Deleting the target directory so that we can start from a clean slate. - try { - await del(targetDirectory, { force: true }) - } catch (_) { - // no-op - } - - // Bundling all functions. - await zipFunctionsAndUpdateTree({ - fileTree, - sourceDirectory, - targetDirectory, - zipCache, - zipOptions, - }) + return { buildPath, srcFiles } } -const getFunctionByName = ({ cache, name }) => [...cache.values()].find((func) => func.name === name) +// Clears the cache for any files inside the directory from which functions are +// served. +const clearFunctionsCache = (functionsPath) => { + Object.keys(require.cache) + .filter((key) => key.startsWith(functionsPath)) + .forEach(decache) +} const getTargetDirectory = async ({ errorExit }) => { const targetDirectory = path.resolve(getPathInProject(['functions-serve'])) @@ -218,44 +67,29 @@ const getTargetDirectory = async ({ errorExit }) => { return targetDirectory } -module.exports = async function handler({ config, errorExit, functionsDirectory: sourceDirectory, projectRoot }) { - const functions = await getFunctions(sourceDirectory) - const hasTSFunction = functions.some(({ mainFile }) => path.extname(mainFile) === '.ts') +module.exports = async ({ config, errorExit, func, functionsDirectory, projectRoot }) => { + const isTSFunction = path.extname(func.mainFile) === '.ts' const functionsConfig = addFunctionsConfigDefaults( normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot }), ) + + // TODO: Resolve functions config globs so that we can check for the bundler + // on a per-function basis. const isUsingEsbuild = functionsConfig['*'].nodeBundler === 'esbuild_zisi' - if (!hasTSFunction && !isUsingEsbuild) { + if (!isTSFunction && !isUsingEsbuild) { return false } // Enable source map support. sourceMapSupport.install() - // Keeps track of which files are associated with each function. - const fileTree = new Map() - - // Used for memoizing calls to ZISI, such that we don't bundle the same - // function multiple times at the same time. - const zipCache = {} const targetDirectory = await getTargetDirectory({ errorExit }) return { - build: (updatedPath, eventType) => - bundleFunctions({ - config: functionsConfig, - eventType, - fileTree, - projectRoot, - sourceDirectory, - targetDirectory, - updatedPath, - zipCache, - }), + build: ({ cache = {} }) => + buildFunction({ cache, config: functionsConfig, func, functionsDirectory, projectRoot, targetDirectory }), builderName: 'zip-it-and-ship-it', - getFunctionByName: (name) => getFunctionByName({ cache: fileTree, name }), - src: sourceDirectory, target: targetDirectory, } } diff --git a/src/lib/functions/runtimes/js/index.js b/src/lib/functions/runtimes/js/index.js new file mode 100644 index 00000000000..2d16fabebc6 --- /dev/null +++ b/src/lib/functions/runtimes/js/index.js @@ -0,0 +1,79 @@ +const { dirname } = require('path') + +const lambdaLocal = require('lambda-local') +const winston = require('winston') + +const detectNetlifyLambdaBuilder = require('./builders/netlify-lambda') +const detectZisiBuilder = require('./builders/zisi') + +const SECONDS_TO_MILLISECONDS = 1e3 + +let netlifyLambdaDetectorCache + +const logger = winston.createLogger({ + levels: winston.config.npm.levels, + transports: [new winston.transports.Console({ level: 'warn' })], +}) + +lambdaLocal.setLogger(logger) + +// The netlify-lambda builder can't be enabled or disabled on a per-function +// basis and its detection mechanism is also quite expensive, so we detect +// it once and cache the result. +const detectNetlifyLambdaWithCache = () => { + if (netlifyLambdaDetectorCache === undefined) { + netlifyLambdaDetectorCache = detectNetlifyLambdaBuilder() + } + + return netlifyLambdaDetectorCache +} + +const getBuildFunction = async ({ config, errorExit, func, functionsDirectory, projectRoot }) => { + const netlifyLambdaBuilder = await detectNetlifyLambdaWithCache() + + if (netlifyLambdaBuilder) { + return netlifyLambdaBuilder.build + } + + const zisiBuilder = await detectZisiBuilder({ config, errorExit, func, functionsDirectory, projectRoot }) + + if (zisiBuilder) { + return zisiBuilder.build + } + + // If there's no function builder, we create a simple one on-the-fly which + // returns as `srcFiles` the function directory, if there is one, or its + // main file otherwise. + const functionDirectory = dirname(func.mainFile) + const srcFiles = functionDirectory === functionsDirectory ? [func.mainFile] : [functionDirectory] + + return () => ({ srcFiles }) +} + +const invokeFunction = async ({ context, event, func, timeout }) => { + // If a function builder has defined a `buildPath` property, we use it. + // Otherwise, we'll invoke the function's main file. + const lambdaPath = (func.buildData && func.buildData.buildPath) || func.mainFile + const { body, statusCode } = await lambdaLocal.execute({ + clientContext: context, + event, + lambdaPath, + timeoutMs: timeout * SECONDS_TO_MILLISECONDS, + verboseLevel: 3, + }) + + return { body, statusCode } +} + +const onDirectoryScan = async () => { + const netlifyLambdaBuilder = await detectNetlifyLambdaWithCache() + + // Before we start a directory scan, we check whether netlify-lambda is being + // used. If it is, we run it, so that the functions directory is populated + // with the compiled files before the scan begins. + if (netlifyLambdaBuilder) { + await netlifyLambdaBuilder.build() + } +} + +module.exports = { getBuildFunction, invokeFunction, name: 'js', onDirectoryScan } diff --git a/src/lib/functions/server.js b/src/lib/functions/server.js index 1b1aa2d2a1c..00f14a9bdc8 100644 --- a/src/lib/functions/server.js +++ b/src/lib/functions/server.js @@ -1,14 +1,12 @@ const bodyParser = require('body-parser') const jwtDecode = require('jwt-decode') -const lambdaLocal = require('lambda-local') -const winston = require('winston') const { NETLIFYDEVERR, NETLIFYDEVLOG } = require('../../utils/logo') -const { executeBackgroundFunction } = require('./background') -const { setupDefaultFunctionHandler, setupFunctionsBuilder } = require('./builder') +const { handleBackgroundFunction, handleBackgroundFunctionResult } = require('./background') const { createFormSubmissionHandler } = require('./form-submissions-handler') -const { executeSynchronousFunction } = require('./synchronous') +const { FunctionsRegistry } = require('./registry') +const { handleSynchronousFunction } = require('./synchronous') const { shouldBase64Encode } = require('./utils') const buildClientContext = function (headers) { @@ -38,26 +36,18 @@ const buildClientContext = function (headers) { } } -const createHandler = function ({ getFunctionByName, timeouts, warn }) { - const logger = winston.createLogger({ - levels: winston.config.npm.levels, - transports: [new winston.transports.Console({ level: 'warn' })], - }) - lambdaLocal.setLogger(logger) - - return function handler(request, response) { +const createHandler = function ({ functionsRegistry }) { + return async 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 = getFunctionByName(functionName) + const func = functionsRegistry.get(functionName) + if (func === undefined) { response.statusCode = 404 response.end('Function not found...') return } - const { bundleFile, mainFile, isBackground } = func - const lambdaPath = bundleFile || mainFile const isBase64Encoded = shouldBase64Encode(request.headers['content-type']) const body = request.get('content-length') ? request.body.toString(isBase64Encoded ? 'base64' : 'utf8') : undefined @@ -98,30 +88,21 @@ const createHandler = function ({ getFunctionByName, timeouts, warn }) { const clientContext = JSON.stringify(buildClientContext(request.headers) || {}) - if (isBackground) { - return executeBackgroundFunction({ - event, - lambdaPath, - timeout: timeouts.backgroundFunctions, - clientContext, - response, - functionName, - warn, - }) - } + if (func.isBackground) { + handleBackgroundFunction(functionName, response) - return executeSynchronousFunction({ - event, - lambdaPath, - timeout: timeouts.syncFunctions, - clientContext, - response, - warn, - }) + const { error } = await func.invoke(event, clientContext) + + handleBackgroundFunctionResult(functionName, error) + } else { + const { error, result } = await func.invoke(event, clientContext) + + handleSynchronousFunction(error, result, response) + } } } -const getFunctionsServer = async function ({ getFunctionByName, siteUrl, warn, timeouts, prefix }) { +const getFunctionsServer = async function ({ functionsRegistry, siteUrl, warn, prefix }) { // performance optimization, load express on demand // eslint-disable-next-line node/global-require const express = require('express') @@ -137,7 +118,7 @@ const getFunctionsServer = async function ({ getFunctionByName, siteUrl, warn, t }), ) app.use(bodyParser.raw({ limit: '6mb', type: '*/*' })) - app.use(createFormSubmissionHandler({ getFunctionByName, siteUrl, warn })) + app.use(createFormSubmissionHandler({ functionsRegistry, siteUrl, warn })) app.use( expressLogging(console, { blacklist: ['/favicon.ico'], @@ -148,7 +129,7 @@ const getFunctionsServer = async function ({ getFunctionByName, siteUrl, warn, t res.status(204).end() }) - app.all(`${prefix}*`, await createHandler({ getFunctionByName, timeouts, warn })) + app.all(`${prefix}*`, await createHandler({ functionsRegistry })) return app } @@ -168,28 +149,24 @@ const startFunctionsServer = async ({ // serve functions from zip-it-and-ship-it // env variables relies on `url`, careful moving this code if (settings.functions) { - const builder = await setupFunctionsBuilder({ + const functionsRegistry = new FunctionsRegistry({ + capabilities, config, errorExit, functionsDirectory: settings.functions, log, - site, + projectRoot: site.root, + timeouts, + warn, }) - const directory = builder.target || settings.functions - - // If the functions builder implements a `getFunctionByName` function, it - // will be called on every functions request with the function name and it - // should return the corresponding function object if one exists. - const { getFunctionByName } = - typeof builder.getFunctionByName === 'function' - ? builder - : await setupDefaultFunctionHandler({ capabilities, directory, warn }) + + await functionsRegistry.scan(settings.functions) + const server = await getFunctionsServer({ - getFunctionByName, + functionsRegistry, siteUrl, - warn, - timeouts, prefix, + warn, }) await startWebServer({ server, settings, log, errorExit }) diff --git a/src/lib/functions/synchronous.js b/src/lib/functions/synchronous.js index 2b23d70f9fe..671b2482590 100644 --- a/src/lib/functions/synchronous.js +++ b/src/lib/functions/synchronous.js @@ -1,55 +1,37 @@ const { Buffer } = require('buffer') -const lambdaLocal = require('lambda-local') - const { NETLIFYDEVERR } = require('../../utils/logo') -const { detectAwsSdkError, DEFAULT_LAMBDA_OPTIONS, SECONDS_TO_MILLISECONDS } = require('./utils') - -const createSynchronousFunctionCallback = function ({ response, warn }) { - return function callbackHandler(err, lambdaResponse) { - if (err) { - return handleErr({ error: err, response, warn }) - } +const handleSynchronousFunction = function (err, result, response) { + if (err) { + return handleErr(err, response) + } - const { error } = validateLambdaResponse(lambdaResponse) - if (error) { - console.log(`${NETLIFYDEVERR} ${error}`) - return handleErr({ error, response, warn }) - } + const { error } = validateLambdaResponse(result) + 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() + response.statusCode = result.statusCode + for (const key in result.headers) { + response.setHeader(key, result.headers[key]) + } + for (const key in result.multiValueHeaders) { + const items = result.multiValueHeaders[key] + response.setHeader(key, items) } + if (result.body) { + response.write(result.isBase64Encoded ? Buffer.from(result.body, 'base64') : result.body) + } + response.end() } -const executeSynchronousFunction = ({ event, lambdaPath, timeout, clientContext, response, warn }) => - lambdaLocal.execute({ - ...DEFAULT_LAMBDA_OPTIONS, - event, - lambdaPath, - clientContext, - callback: createSynchronousFunctionCallback({ response, warn }), - timeoutMs: timeout * SECONDS_TO_MILLISECONDS, - }) - const formatLambdaLocalError = (err) => `${err.errorType}: ${err.errorMessage}\n ${err.stackTrace.join('\n ')}` -const handleErr = function ({ error, response, warn }) { - detectAwsSdkError({ error, warn }) - +const handleErr = function (err, response) { response.statusCode = 500 - const errorString = typeof error === 'string' ? error : formatLambdaLocalError(error) + const errorString = typeof err === 'string' ? err : formatLambdaLocalError(err) response.end(errorString) } @@ -69,4 +51,4 @@ const validateLambdaResponse = (lambdaResponse) => { return {} } -module.exports = { executeSynchronousFunction } +module.exports = { handleSynchronousFunction } diff --git a/src/lib/functions/utils.js b/src/lib/functions/utils.js index b79e87e1caa..c5e09d84f12 100644 --- a/src/lib/functions/utils.js +++ b/src/lib/functions/utils.js @@ -1,6 +1,5 @@ const chalk = require('chalk') -const { NETLIFYDEVLOG } = require('../../utils/logo') const { getLogMessage } = require('../log') const BASE_64_MIME_REGEXP = /image|audio|video|application\/pdf|application\/zip|applicaton\/octet-stream/i @@ -21,34 +20,17 @@ const detectAwsSdkError = ({ error, warn }) => { const formatLambdaError = (err) => chalk.red(`${err.errorType}: ${err.errorMessage}`) -const logAfterAction = ({ path, action }) => { - console.log(`${NETLIFYDEVLOG} ${path} ${action}, successfully reloaded!`) -} - -const logBeforeAction = ({ path, action }) => { - console.log(`${NETLIFYDEVLOG} ${path} ${action}, reloading...`) -} - const shouldBase64Encode = function (contentType) { return Boolean(contentType) && BASE_64_MIME_REGEXP.test(contentType) } const styleFunctionName = (name) => chalk.magenta(name) -const validateFunctions = function ({ functions, capabilities, warn }) { - if (!capabilities.backgroundFunctions && functions.some(({ isBackground }) => isBackground)) { - warn(getLogMessage('functions.backgroundNotSupported')) - } -} - module.exports = { detectAwsSdkError, DEFAULT_LAMBDA_OPTIONS, formatLambdaError, - logAfterAction, - logBeforeAction, SECONDS_TO_MILLISECONDS, shouldBase64Encode, styleFunctionName, - validateFunctions, } diff --git a/src/lib/functions/watcher.js b/src/lib/functions/watcher.js index 0a01aed4a3e..93275bb0821 100644 --- a/src/lib/functions/watcher.js +++ b/src/lib/functions/watcher.js @@ -3,50 +3,34 @@ const decache = require('decache') const debounce = require('lodash/debounce') const pEvent = require('p-event') -const DEBOUNCE_WAIT = 300 +const DEBOUNCE_WAIT = 100 const watchDebounced = async (target, { depth, onAdd, onChange, onUnlink }) => { const watcher = chokidar.watch(target, { depth, ignored: /node_modules/, ignoreInitial: true }) - const debounceOptions = { - leading: false, - trailing: true, - } await pEvent(watcher, 'ready') - const debouncedOnChange = debounce( - (path) => { - decache(path) - - if (typeof onChange === 'function') { - onChange(path) - } - }, - DEBOUNCE_WAIT, - debounceOptions, - ) - const debouncedOnUnlink = debounce( - (path) => { - decache(path) - - if (typeof onUnlink === 'function') { - onUnlink(path) - } - }, - DEBOUNCE_WAIT, - debounceOptions, - ) - const debouncedOnAdd = debounce( - (path) => { - decache(path) - - if (typeof onAdd === 'function') { - onAdd(path) - } - }, - DEBOUNCE_WAIT, - debounceOptions, - ) + const debouncedOnChange = debounce((path) => { + decache(path) + + if (typeof onChange === 'function') { + onChange(path) + } + }, DEBOUNCE_WAIT) + const debouncedOnUnlink = debounce((path) => { + decache(path) + + if (typeof onUnlink === 'function') { + onUnlink(path) + } + }, DEBOUNCE_WAIT) + const debouncedOnAdd = debounce((path) => { + decache(path) + + if (typeof onAdd === 'function') { + onAdd(path) + } + }, DEBOUNCE_WAIT) watcher.on('change', debouncedOnChange).on('unlink', debouncedOnUnlink).on('add', debouncedOnAdd) diff --git a/src/utils/difference.js b/src/utils/difference.js new file mode 100644 index 00000000000..d82e698d8d4 --- /dev/null +++ b/src/utils/difference.js @@ -0,0 +1,4 @@ +// Returns a new set with all elements of `setA` that don't exist in `setB`. +const difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item))) + +module.exports = { difference } diff --git a/tests/serving-functions.test.js b/tests/serving-functions.test.js index 3e9ad2b064f..917d9ff5645 100644 --- a/tests/serving-functions.test.js +++ b/tests/serving-functions.test.js @@ -15,7 +15,7 @@ const testMatrix = [{ args: [] }, { args: ['esbuild'] }] const testName = (title, args) => (args.length <= 0 ? title : `${title} - ${args.join(' ')}`) const WAIT_INTERVAL = 1800 -const WAIT_TIMEOUT = 10000 +const WAIT_TIMEOUT = 30000 const gotCatch404 = async (url, options) => { try {