diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index b843f3aca4d..853cd1185ec 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -22,6 +22,7 @@ "@sindresorhus/slugify": "^1.1.0", "ansi-escapes": "^5.0.0", "ansi-styles": "^5.0.0", + "ansi2html": "^0.0.1", "ascii-table": "0.0.9", "backoff": "^2.5.0", "better-opn": "^3.0.0", @@ -36,6 +37,7 @@ "content-type": "^1.0.4", "cookie": "^0.4.0", "copy-template-dir": "^1.4.0", + "cron-parser": "^4.2.1", "debug": "^4.1.1", "decache": "^4.6.0", "del": "^6.0.0", @@ -134,7 +136,6 @@ "ini": "^2.0.0", "jsonwebtoken": "^8.5.1", "mock-fs": "^5.1.2", - "mock-require": "^3.0.3", "p-timeout": "^4.0.0", "proxyquire": "^2.1.3", "seedrandom": "^3.0.5", @@ -4426,6 +4427,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi2html": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/ansi2html/-/ansi2html-0.0.1.tgz", + "integrity": "sha1-u4gARhtECvALkb89c2al4LhHO6g=", + "bin": { + "ansi2html": "bin/ansi2html" + }, + "engines": { + "node": ">0.4" + } + }, "node_modules/any-observable": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", @@ -10887,12 +10899,6 @@ "node": ">=6.0" } }, - "node_modules/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true - }, "node_modules/get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -14770,31 +14776,6 @@ "node": ">=12.0.0" } }, - "node_modules/mock-require": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", - "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", - "dev": true, - "dependencies": { - "get-caller-file": "^1.0.2", - "normalize-path": "^2.1.1" - }, - "engines": { - "node": ">=4.3.0" - } - }, - "node_modules/mock-require/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/module-definition": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-3.3.1.tgz", @@ -24813,6 +24794,11 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, + "ansi2html": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/ansi2html/-/ansi2html-0.0.1.tgz", + "integrity": "sha1-u4gARhtECvALkb89c2al4LhHO6g=" + }, "any-observable": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", @@ -29735,12 +29721,6 @@ "node-source-walk": "^4.0.0" } }, - "get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true - }, "get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -32621,27 +32601,6 @@ "integrity": "sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A==", "dev": true }, - "mock-require": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", - "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", - "dev": true, - "requires": { - "get-caller-file": "^1.0.2", - "normalize-path": "^2.1.1" - }, - "dependencies": { - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } - } - } - }, "module-definition": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-3.3.1.tgz", diff --git a/package.json b/package.json index 95c2ce538f9..3fec8304dbe 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@sindresorhus/slugify": "^1.1.0", "ansi-escapes": "^5.0.0", "ansi-styles": "^5.0.0", + "ansi2html": "^0.0.1", "ascii-table": "0.0.9", "backoff": "^2.5.0", "better-opn": "^3.0.0", @@ -103,6 +104,7 @@ "content-type": "^1.0.4", "cookie": "^0.4.0", "copy-template-dir": "^1.4.0", + "cron-parser": "^4.2.1", "debug": "^4.1.1", "decache": "^4.6.0", "del": "^6.0.0", @@ -197,7 +199,6 @@ "ini": "^2.0.0", "jsonwebtoken": "^8.5.1", "mock-fs": "^5.1.2", - "mock-require": "^3.0.3", "p-timeout": "^4.0.0", "proxyquire": "^2.1.3", "seedrandom": "^3.0.5", diff --git a/src/commands/functions/functions-invoke.js b/src/commands/functions/functions-invoke.js index 56ef8370fa6..0ec808b89b3 100644 --- a/src/commands/functions/functions-invoke.js +++ b/src/commands/functions/functions-invoke.js @@ -3,10 +3,11 @@ const fs = require('fs') const path = require('path') const process = require('process') +const CronParser = require('cron-parser') const inquirer = require('inquirer') const fetch = require('node-fetch') -const { BACKGROUND, NETLIFYDEVWARN, chalk, error, exit, getFunctions } = require('../../utils') +const { BACKGROUND, CLOCKWORK_USERAGENT, NETLIFYDEVWARN, chalk, error, exit, getFunctions } = require('../../utils') // https://www.netlify.com/docs/functions/#event-triggered-functions const events = [ @@ -130,6 +131,13 @@ const getFunctionToTrigger = function (options, argumentName) { return argumentName } +const getNextRun = function (schedule) { + const cron = CronParser.parseExpression(schedule, { + tz: 'Etc/UTC', + }) + return cron.next().toDate() +} + /** * The functions:invoke command * @param {string} nameArgument @@ -150,11 +158,18 @@ const functionsInvoke = async (nameArgument, options, command) => { const functions = await getFunctions(functionsDir) const functionToTrigger = await getNameFromArgs(functions, options, nameArgument) + const functionObj = functions.find((func) => func.name === functionToTrigger) let headers = {} let body = {} - if (eventTriggeredFunctions.has(functionToTrigger)) { + if (functionObj.schedule) { + body.next_run = getNextRun(functionObj.schedule) + headers = { + 'user-agent': CLOCKWORK_USERAGENT, + 'X-NF-Event': 'schedule', + } + } else if (eventTriggeredFunctions.has(functionToTrigger)) { /** handle event triggered fns */ // https://www.netlify.com/docs/functions/#event-triggered-functions const [name, event] = functionToTrigger.split('-') diff --git a/src/lib/functions/netlify-function.js b/src/lib/functions/netlify-function.js index 12787321d08..e7fbe9c9f5f 100644 --- a/src/lib/functions/netlify-function.js +++ b/src/lib/functions/netlify-function.js @@ -32,6 +32,7 @@ class NetlifyFunction { // Determines whether this is a background function based on the function // name. this.isBackground = name.endsWith(BACKGROUND_SUFFIX) + this.schedule = null // List of the function's source files. This starts out as an empty set // and will get populated on every build. @@ -44,6 +45,12 @@ class NetlifyFunction { return /^[A-Za-z0-9_-]+$/.test(this.name) } + async isScheduled() { + await this.buildQueue + + return Boolean(this.schedule) + } + // The `build` method transforms source files into invocable functions. Its // return value is an object with: // @@ -61,12 +68,13 @@ class NetlifyFunction { this.buildQueue = buildFunction({ cache }) try { - const { srcFiles, ...buildData } = await this.buildQueue + const { schedule, srcFiles, ...buildData } = await this.buildQueue const srcFilesSet = new Set(srcFiles) const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet) this.buildData = buildData this.srcFiles = srcFilesSet + this.schedule = schedule return { srcFilesDiff } } catch (error) { diff --git a/src/lib/functions/runtimes/js/builders/zisi.js b/src/lib/functions/runtimes/js/builders/zisi.js index c5ddfa96e00..dd92c94bf9b 100644 --- a/src/lib/functions/runtimes/js/builders/zisi.js +++ b/src/lib/functions/runtimes/js/builders/zisi.js @@ -1,7 +1,7 @@ const { mkdir, writeFile } = require('fs').promises const path = require('path') -const { zipFunction } = require('@netlify/zip-it-and-ship-it') +const { listFunction, zipFunction } = require('@netlify/zip-it-and-ship-it') const decache = require('decache') const readPkgUp = require('read-pkg-up') const sourceMapSupport = require('source-map-support') @@ -35,7 +35,11 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr // 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 === directory ? func.mainFile : functionDirectory - const { inputs, path: functionPath } = await memoizedBuild({ + const { + inputs, + path: functionPath, + schedule, + } = await memoizedBuild({ cache, cacheKey: `zisi-${entryPath}`, command: () => zipFunction(entryPath, targetDirectory, zipOptions), @@ -56,7 +60,22 @@ const buildFunction = async ({ cache, config, directory, func, hasTypeModule, pr clearFunctionsCache(targetDirectory) - return { buildPath, srcFiles } + return { buildPath, srcFiles, schedule } +} + +/** + * @param {object} params + * @param {unknown} params.config + * @param {string} params.mainFile + * @param {string} params.projectRoot + */ +const parseForSchedule = async ({ config, mainFile, projectRoot }) => { + const listedFunction = await listFunction(mainFile, { + config: netlifyConfigToZisiConfig({ config, projectRoot }), + parseISC: true, + }) + + return listedFunction && listedFunction.schedule } // Clears the cache for any files inside the directory from which functions are @@ -79,10 +98,11 @@ const getTargetDirectory = async ({ errorExit }) => { return targetDirectory } +const netlifyConfigToZisiConfig = ({ config, projectRoot }) => + addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot })) + module.exports = async ({ config, directory, errorExit, func, projectRoot }) => { - const functionsConfig = addFunctionsConfigDefaults( - normalizeFunctionsConfig({ functionsConfig: config.functions, projectRoot }), - ) + const functionsConfig = netlifyConfigToZisiConfig({ config, projectRoot }) const packageJson = await readPkgUp(func.mainFile) const hasTypeModule = packageJson && packageJson.packageJson.type === 'module' @@ -115,3 +135,5 @@ module.exports = async ({ config, directory, errorExit, func, projectRoot }) => target: targetDirectory, } } + +module.exports.parseForSchedule = parseForSchedule diff --git a/src/lib/functions/runtimes/js/index.js b/src/lib/functions/runtimes/js/index.js index cfd41242c9c..5a0786ac900 100644 --- a/src/lib/functions/runtimes/js/index.js +++ b/src/lib/functions/runtimes/js/index.js @@ -46,8 +46,9 @@ const getBuildFunction = async ({ config, directory, errorExit, func, projectRoo // main file otherwise. const functionDirectory = dirname(func.mainFile) const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory] + const schedule = await detectZisiBuilder.parseForSchedule({ mainFile: func.mainFile, config, projectRoot }) - return () => ({ srcFiles }) + return () => ({ schedule, srcFiles }) } const invokeFunction = async ({ context, event, func, timeout }) => { diff --git a/src/lib/functions/scheduled.js b/src/lib/functions/scheduled.js new file mode 100644 index 00000000000..7c78aa1ae0e --- /dev/null +++ b/src/lib/functions/scheduled.js @@ -0,0 +1,98 @@ +const ansi2html = require('ansi2html') + +const { CLOCKWORK_USERAGENT } = require('../../utils') + +const { formatLambdaError } = require('./utils') + +const buildHelpResponse = ({ error, headers, path, result }) => { + const acceptsHtml = headers.accept && headers.accept.includes('text/html') + + const paragraph = (text) => { + text = text.trim() + + if (acceptsHtml) { + return ansi2html(`

${text}

`) + } + + text = text + .replace(/
/gm, '```\n')
+      .replace(/<\/code><\/pre>/gm, '\n```')
+      .replace(//gm, '`')
+      .replace(/<\/code>/gm, '`')
+
+    return `${text}\n\n`
+  }
+
+  const isSimulatedRequest = headers['user-agent'] === CLOCKWORK_USERAGENT
+
+  let message = ''
+
+  if (!isSimulatedRequest) {
+    message += paragraph(`
+You performed an HTTP request to ${path}, which is a scheduled function.
+You can do this to test your functions locally, but it won't work in production.
+    `)
+  }
+
+  if (error) {
+    message += paragraph(`
+There was an error during execution of your scheduled function:
+
+
${formatLambdaError(error)}
`) + } + + if (result) { + // lambda emulator adds level field, which isn't user-provided + const returnValue = { ...result } + delete returnValue.level + + const { statusCode } = returnValue + if (statusCode >= 500) { + message += paragraph(` +Your function returned a status code of ${statusCode}. +At the moment, Netlify does nothing about that. In the future, there might be a retry mechanism based on this. +`) + } + + const allowedKeys = new Set(['statusCode']) + const returnedKeys = Object.keys(returnValue) + const ignoredKeys = returnedKeys.filter((key) => !allowedKeys.has(key)) + + if (ignoredKeys.length !== 0) { + message += paragraph( + `Your function returned ${ignoredKeys + .map((key) => `${key}`) + .join(', ')}. Is this an accident? It won't be interpreted by Netlify.`, + ) + } + } + + const statusCode = error ? 500 : 200 + return acceptsHtml + ? { + statusCode, + contentType: 'text/html', + message: `\n + ${message}`, + } + : { + statusCode, + contentType: 'text/plain', + message, + } +} + +const handleScheduledFunction = ({ error, request, response, result }) => { + const { contentType, message, statusCode } = buildHelpResponse({ + error, + headers: request.headers, + path: request.path, + result, + }) + + response.status(statusCode) + response.set('Content-Type', contentType) + response.send(message) +} + +module.exports = { handleScheduledFunction, buildHelpResponse } diff --git a/src/lib/functions/scheduled.test.js b/src/lib/functions/scheduled.test.js new file mode 100644 index 00000000000..24013abea86 --- /dev/null +++ b/src/lib/functions/scheduled.test.js @@ -0,0 +1,58 @@ +const test = require('ava') + +const { buildHelpResponse } = require('./scheduled') + +const withAccept = (accept) => + buildHelpResponse({ + error: undefined, + headers: { + accept, + }, + path: '/', + result: { + statusCode: 200, + }, + }) + +test('buildHelpResponse does content negotiation', (t) => { + const html = withAccept('text/html') + t.is(html.contentType, 'text/html') + t.true(html.message.includes('')) + + const plain = withAccept('text/plain') + t.is(plain.contentType, 'text/plain') + t.false(plain.message.includes('')) +}) + +test('buildHelpResponse prints errors', (t) => { + const response = buildHelpResponse({ + error: new Error('test'), + headers: {}, + path: '/', + result: { + statusCode: 200, + }, + }) + + t.true(response.message.includes('There was an error')) +}) + +const withUserAgent = (userAgent) => + buildHelpResponse({ + error: new Error('test'), + headers: { + accept: 'text/plain', + 'user-agent': userAgent, + }, + path: '/', + result: { + statusCode: 200, + }, + }) + +test('buildHelpResponse conditionally prints notice about HTTP x scheduled functions', (t) => { + t.true(withUserAgent('').message.includes("it won't work in production")) + t.false(withUserAgent('Netlify Clockwork').message.includes("it won't work in production")) +}) diff --git a/src/lib/functions/server.js b/src/lib/functions/server.js index 48e8eeb928a..bbe03d3bfb8 100644 --- a/src/lib/functions/server.js +++ b/src/lib/functions/server.js @@ -6,6 +6,7 @@ const { NETLIFYDEVERR, NETLIFYDEVLOG, error: errorExit, getInternalFunctionsDir, const { handleBackgroundFunction, handleBackgroundFunctionResult } = require('./background') const { createFormSubmissionHandler } = require('./form-submissions-handler') const { FunctionsRegistry } = require('./registry') +const { handleScheduledFunction } = require('./scheduled') const { handleSynchronousFunction } = require('./synchronous') const { shouldBase64Encode } = require('./utils') @@ -105,6 +106,15 @@ const createHandler = function ({ functionsRegistry }) { const { error } = await func.invoke(event, clientContext) handleBackgroundFunctionResult(functionName, error) + } else if (await func.isScheduled()) { + const { error, result } = await func.invoke(event, clientContext) + + handleScheduledFunction({ + error, + result, + request, + response, + }) } else { const { error, result } = await func.invoke(event, clientContext) diff --git a/src/lib/functions/server.test.js b/src/lib/functions/server.test.js index 17f4b1d34e4..1e7906cc3e6 100644 --- a/src/lib/functions/server.test.js +++ b/src/lib/functions/server.test.js @@ -1,42 +1,25 @@ -const fs = require('fs') -const { platform } = require('os') +const { mkdirSync, mkdtempSync, writeFileSync } = require('fs') +const { tmpdir } = require('os') const { join } = require('path') -const zisi = require('@netlify/zip-it-and-ship-it') const test = require('ava') const express = require('express') -const mockRequire = require('mock-require') -const sinon = require('sinon') const request = require('supertest') -const projectRoot = platform() === 'win32' ? 'C:\\my-functions' : `/my-functions` -const functionsPath = `functions` - -// mock mkdir for the functions folder -sinon.stub(fs.promises, 'mkdir').withArgs(join(projectRoot, functionsPath)).returns(Promise.resolve()) - const { FunctionsRegistry } = require('./registry') const { createHandler } = require('./server') /** @type { express.Express} */ let app -test.before(async (t) => { - const mainFile = join(projectRoot, functionsPath, 'hello.js') - t.context.zisiStub = sinon.stub(zisi, 'listFunctions').returns( - Promise.resolve([ - { - name: 'hello', - mainFile, - runtime: 'js', - extension: '.js', - }, - ]), - ) +test.before(async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'functions-server-project-root')) + const functionsDirectory = join(projectRoot, 'functions') + mkdirSync(functionsDirectory) + + const mainFile = join(functionsDirectory, 'hello.js') + writeFileSync(mainFile, `exports.handler = (event) => ({ statusCode: 200, body: event.rawUrl })`) - mockRequire(mainFile, { - handler: (event) => ({ statusCode: 200, body: event.rawUrl }), - }) const functionsRegistry = new FunctionsRegistry({ projectRoot, config: {}, @@ -44,15 +27,11 @@ test.before(async (t) => { // eslint-disable-next-line no-magic-numbers settings: { port: 8888 }, }) - await functionsRegistry.scan([functionsPath]) + await functionsRegistry.scan([functionsDirectory]) app = express() app.all('*', createHandler({ functionsRegistry })) }) -test.after((t) => { - t.context.zisiStub.restore() -}) - test('should get the url as the `rawUrl` inside the function', async (t) => { await request(app) .get('/hello') diff --git a/src/utils/functions/constants.js b/src/utils/functions/constants.js new file mode 100644 index 00000000000..061d31aca74 --- /dev/null +++ b/src/utils/functions/constants.js @@ -0,0 +1,5 @@ +const CLOCKWORK_USERAGENT = 'Netlify Clockwork' + +module.exports = { + CLOCKWORK_USERAGENT, +} diff --git a/src/utils/functions/get-functions.js b/src/utils/functions/get-functions.js index 9f9c4de8a8d..9f8cef2e8fe 100644 --- a/src/utils/functions/get-functions.js +++ b/src/utils/functions/get-functions.js @@ -5,10 +5,10 @@ const getUrlPath = (functionName) => `/.netlify/functions/${functionName}` const BACKGROUND = '-background' -const addFunctionProps = ({ mainFile, name, runtime }) => { +const addFunctionProps = ({ mainFile, name, runtime, schedule }) => { const urlPath = getUrlPath(name) const isBackground = name.endsWith(BACKGROUND) - return { mainFile, name, runtime, urlPath, isBackground } + return { mainFile, name, runtime, urlPath, isBackground, schedule } } const JS = 'js' @@ -21,7 +21,9 @@ const getFunctions = async (functionsSrcDir) => { // 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') - const functions = await listFunctions(functionsSrcDir) + const functions = await listFunctions(functionsSrcDir, { + parseISC: true, + }) const functionsWithProps = functions.filter(({ runtime }) => runtime === JS).map((func) => addFunctionProps(func)) return functionsWithProps } diff --git a/src/utils/functions/get-functions.test.js b/src/utils/functions/get-functions.test.js index 407f3848f15..ff79f34397f 100644 --- a/src/utils/functions/get-functions.test.js +++ b/src/utils/functions/get-functions.test.js @@ -37,6 +37,7 @@ test('should return object with function details for a directory with js files', mainFile: path.join(builder.directory, 'functions', 'index.js'), isBackground: false, runtime: 'js', + schedule: undefined, urlPath: '/.netlify/functions/index', }, ]) @@ -64,6 +65,7 @@ test('should mark background functions based on filenames', async (t) => { mainFile: path.join(builder.directory, 'functions', 'bar-background', 'bar-background.js'), isBackground: true, runtime: 'js', + schedule: undefined, urlPath: '/.netlify/functions/bar-background', }, { @@ -71,6 +73,7 @@ test('should mark background functions based on filenames', async (t) => { mainFile: path.join(builder.directory, 'functions', 'foo-background.js'), isBackground: true, runtime: 'js', + schedule: undefined, urlPath: '/.netlify/functions/foo-background', }, ]) diff --git a/src/utils/functions/index.js b/src/utils/functions/index.js index 8606ce08894..b5952f47014 100644 --- a/src/utils/functions/index.js +++ b/src/utils/functions/index.js @@ -1,8 +1,10 @@ +const constants = require('./constants') const edgeHandlers = require('./edge-handlers') const functions = require('./functions') const getFunctions = require('./get-functions') module.exports = { + ...constants, ...functions, ...edgeHandlers, ...getFunctions, diff --git a/tests/command.functions.test.js b/tests/command.functions.test.js index a3542b44eb2..74963e88447 100644 --- a/tests/command.functions.test.js +++ b/tests/command.functions.test.js @@ -13,6 +13,7 @@ const { withDevServer } = require('./utils/dev-server') const got = require('./utils/got') const { CONFIRM, DOWN, answerWithValue, handleQuestions } = require('./utils/handle-questions') const { withMockApi } = require('./utils/mock-api') +const { pause } = require('./utils/pause') const { killProcess } = require('./utils/process') const { withSiteBuilder } = require('./utils/site-builder') @@ -582,6 +583,129 @@ test('should trigger background function from event', async (t) => { }) }) +test('should serve helpful tips and tricks', async (t) => { + await withSiteBuilder('site-with-isc-ping-function', async (builder) => { + await builder + .withNetlifyToml({ + config: { functions: { directory: 'functions' } }, + }) + // mocking until https://github.com/netlify/functions/pull/226 landed + .withContentFile({ + path: 'node_modules/@netlify/functions/package.json', + content: `{}`, + }) + .withContentFile({ + path: 'node_modules/@netlify/functions/index.js', + content: ` + module.exports.schedule = (schedule, handler) => handler + `, + }) + .withContentFile({ + path: 'functions/hello-world.js', + content: ` + const { schedule } = require('@netlify/functions') + + module.exports.handler = schedule('@daily', () => { + return { + statusCode: 200, + body: "hello world" + } + }) + `.trim(), + }) + .buildAsync() + + await withDevServer({ cwd: builder.directory }, async (server) => { + const plainTextResponse = await got(`http://localhost:${server.port}/.netlify/functions/hello-world`, { + throwHttpErrors: false, + retry: null, + }) + const youReturnedBodyRegex = /.*Your function returned `body`. Is this an accident\?.*/ + t.regex(plainTextResponse.body, youReturnedBodyRegex) + t.regex(plainTextResponse.body, /.*You performed an HTTP request.*/) + t.is(plainTextResponse.statusCode, 200) + + const htmlResponse = await got(`http://localhost:${server.port}/.netlify/functions/hello-world`, { + throwHttpErrors: false, + retry: null, + headers: { + accept: 'text/html', + }, + }) + t.regex(htmlResponse.body, /.* { + await withSiteBuilder('site-with-isc-ping-function', async (builder) => { + await builder + .withNetlifyToml({ + config: { functions: { directory: 'functions' } }, + }) + // mocking until https://github.com/netlify/functions/pull/226 landed + .withContentFile({ + path: 'node_modules/@netlify/functions/package.json', + content: `{}`, + }) + .withContentFile({ + path: 'node_modules/@netlify/functions/index.js', + content: ` + module.exports.schedule = (schedule, handler) => handler + `, + }) + .withContentFile({ + path: 'functions/hello-world.js', + content: ` + module.exports.handler = () => { + return { + statusCode: 200 + } + } + `.trim(), + }) + .buildAsync() + + await withDevServer({ cwd: builder.directory }, async (server) => { + const helloWorldBody = () => + got(`http://localhost:${server.port}/.netlify/functions/hello-world`, { + throwHttpErrors: false, + retry: null, + }).then((response) => response.body) + + t.is(await helloWorldBody(), '') + + await builder + .withContentFile({ + path: 'functions/hello-world.js', + content: ` + const { schedule } = require('@netlify/functions') + + module.exports.handler = schedule("@daily", () => { + return { + statusCode: 200, + body: "test" + } + }) + `.trim(), + }) + .buildAsync() + + const DETECT_FILE_CHANGE_DELAY = 500 + await pause(DETECT_FILE_CHANGE_DELAY) + + const warningMessage = await helloWorldBody() + t.true(warningMessage.includes('Your function returned `body`')) + }) + }) +}) + test('should inject env variables', async (t) => { await withSiteBuilder('site-with-env-function', async (builder) => { await builder