From 25f3e2fb82618d89c22b46ebabdcfb651d752b94 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 30 Jul 2022 11:19:40 +0100 Subject: [PATCH 01/19] feat: add static analysis helper --- demos/default/pages/api/hello-background.ts | 9 ++++++ demos/default/pages/api/hello-scheduled.js | 9 ++++++ plugin/src/helpers/analysis.ts | 21 ++++++++++++ test/analysis.spec.ts | 32 +++++++++++++++++++ test/fixtures/analysis/background.js | 9 ++++++ test/fixtures/analysis/background.ts | 9 ++++++ test/fixtures/analysis/invalid.ts | 11 +++++++ .../fixtures/analysis/missing.ts | 0 test/fixtures/analysis/scheduled.js | 9 ++++++ test/fixtures/analysis/scheduled.ts | 9 ++++++ 10 files changed, 118 insertions(+) create mode 100644 demos/default/pages/api/hello-background.ts create mode 100644 demos/default/pages/api/hello-scheduled.js create mode 100644 plugin/src/helpers/analysis.ts create mode 100644 test/analysis.spec.ts create mode 100644 test/fixtures/analysis/background.js create mode 100644 test/fixtures/analysis/background.ts create mode 100644 test/fixtures/analysis/invalid.ts rename demos/default/pages/api/hello-background.js => test/fixtures/analysis/missing.ts (100%) create mode 100644 test/fixtures/analysis/scheduled.js create mode 100644 test/fixtures/analysis/scheduled.ts diff --git a/demos/default/pages/api/hello-background.ts b/demos/default/pages/api/hello-background.ts new file mode 100644 index 0000000000..c50f057f7d --- /dev/null +++ b/demos/default/pages/api/hello-background.ts @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + background: true, +} diff --git a/demos/default/pages/api/hello-scheduled.js b/demos/default/pages/api/hello-scheduled.js new file mode 100644 index 0000000000..23b287034c --- /dev/null +++ b/demos/default/pages/api/hello-scheduled.js @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + schedule: '@hourly', +} diff --git a/plugin/src/helpers/analysis.ts b/plugin/src/helpers/analysis.ts new file mode 100644 index 0000000000..22a7b12b04 --- /dev/null +++ b/plugin/src/helpers/analysis.ts @@ -0,0 +1,21 @@ +import fs from 'fs' +import { relative } from 'path' + +import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value' +import { parseModule } from 'next/dist/build/analysis/parse-module' + +export const extractConfigFromFile = async (apiFilePath: string): Promise> => { + const fileContent = await fs.promises.readFile(apiFilePath, 'utf8') + if (!fileContent.includes('config')) { + return {} + } + const ast = await parseModule(apiFilePath, fileContent) + try { + return extractExportedConstValue(ast, 'config') + } catch (error) { + if (error instanceof UnsupportedValueError) { + console.warn(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`) + } + return {} + } +} diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts new file mode 100644 index 0000000000..abee481768 --- /dev/null +++ b/test/analysis.spec.ts @@ -0,0 +1,32 @@ +import { extractConfigFromFile } from '../plugin/src/helpers/analysis' +import { resolve } from 'path' +describe('static source analysis', () => { + it('should extract config values from a source file', async () => { + const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/background.js')) + expect(config).toEqual({ + background: true, + }) + }) + it('should extract config values from a TypeScript source file', async () => { + const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/background.ts')) + expect(config).toEqual({ + background: true, + }) + }) + it('should return an empty config if not defined', async () => { + const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/missing.ts')) + expect(config).toEqual({}) + }) + + it('should return an empty config if config is invalid', async () => { + const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/invalid.ts')) + expect(config).toEqual({}) + }) + + it('should extract schedule values from a source file', async () => { + const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/scheduled.ts')) + expect(config).toEqual({ + schedule: '@daily', + }) + }) +}) diff --git a/test/fixtures/analysis/background.js b/test/fixtures/analysis/background.js new file mode 100644 index 0000000000..c50f057f7d --- /dev/null +++ b/test/fixtures/analysis/background.js @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + background: true, +} diff --git a/test/fixtures/analysis/background.ts b/test/fixtures/analysis/background.ts new file mode 100644 index 0000000000..c50f057f7d --- /dev/null +++ b/test/fixtures/analysis/background.ts @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + background: true, +} diff --git a/test/fixtures/analysis/invalid.ts b/test/fixtures/analysis/invalid.ts new file mode 100644 index 0000000000..26cc61fda8 --- /dev/null +++ b/test/fixtures/analysis/invalid.ts @@ -0,0 +1,11 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + background() { + return true + }, +} diff --git a/demos/default/pages/api/hello-background.js b/test/fixtures/analysis/missing.ts similarity index 100% rename from demos/default/pages/api/hello-background.js rename to test/fixtures/analysis/missing.ts diff --git a/test/fixtures/analysis/scheduled.js b/test/fixtures/analysis/scheduled.js new file mode 100644 index 0000000000..23b287034c --- /dev/null +++ b/test/fixtures/analysis/scheduled.js @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + schedule: '@hourly', +} diff --git a/test/fixtures/analysis/scheduled.ts b/test/fixtures/analysis/scheduled.ts new file mode 100644 index 0000000000..86de7d9950 --- /dev/null +++ b/test/fixtures/analysis/scheduled.ts @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + schedule: '@daily', +} From fbfd0cca88890ede0f78c42f4004655e2fd0e701 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 Jul 2022 07:28:23 +0100 Subject: [PATCH 02/19] feat: split api routes into separate functions --- plugin/src/helpers/analysis.ts | 8 +- plugin/src/helpers/config.ts | 2 +- plugin/src/helpers/files.ts | 20 ++- plugin/src/helpers/functions.ts | 63 ++++++-- plugin/src/helpers/redirects.ts | 5 +- plugin/src/helpers/utils.ts | 62 ++++++-- plugin/src/index.ts | 8 +- plugin/src/templates/getApiHandler.ts | 136 ++++++++++++++++++ plugin/src/templates/getHandler.ts | 6 +- plugin/src/templates/getPageResolver.ts | 25 ++++ test/analysis.spec.ts | 20 ++- test/fixtures/analysis/background.js.nft.json | 1 + 12 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 plugin/src/templates/getApiHandler.ts create mode 100644 test/fixtures/analysis/background.js.nft.json diff --git a/plugin/src/helpers/analysis.ts b/plugin/src/helpers/analysis.ts index 22a7b12b04..3a00f3434f 100644 --- a/plugin/src/helpers/analysis.ts +++ b/plugin/src/helpers/analysis.ts @@ -4,7 +4,13 @@ import { relative } from 'path' import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value' import { parseModule } from 'next/dist/build/analysis/parse-module' -export const extractConfigFromFile = async (apiFilePath: string): Promise> => { +export interface ApiConfig { + runtime?: 'node' | 'experimental-edge' + background?: boolean + schedule?: string +} + +export const extractConfigFromFile = async (apiFilePath: string): Promise => { const fileContent = await fs.promises.readFile(apiFilePath, 'utf8') if (!fileContent.includes('config')) { return {} diff --git a/plugin/src/helpers/config.ts b/plugin/src/helpers/config.ts index 09902a0615..921e22bcfe 100644 --- a/plugin/src/helpers/config.ts +++ b/plugin/src/helpers/config.ts @@ -89,7 +89,7 @@ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore netlifyConfig.functions._ipx.node_bundler = 'nft' /* eslint-enable no-underscore-dangle */ - ;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME].forEach((functionName) => { + ;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'].forEach((functionName) => { netlifyConfig.functions[functionName] ||= { included_files: [], external_node_modules: [] } netlifyConfig.functions[functionName].node_bundler = 'nft' netlifyConfig.functions[functionName].included_files ||= [] diff --git a/plugin/src/helpers/files.ts b/plugin/src/helpers/files.ts index 1e7971abdf..f8f247768e 100644 --- a/plugin/src/helpers/files.ts +++ b/plugin/src/helpers/files.ts @@ -8,7 +8,7 @@ import globby from 'globby' import { PrerenderManifest } from 'next/dist/build' import { outdent } from 'outdent' import pLimit from 'p-limit' -import { join } from 'pathe' +import { join, resolve } from 'pathe' import slash from 'slash' import { MINIMUM_REVALIDATE_SECONDS, DIVIDER } from '../constants' @@ -325,6 +325,24 @@ const getServerFile = (root: string, includeBase = true) => { return findModuleFromBase({ candidates, paths: [root] }) } +export const getSourceFileForPage = (page: string, root: string) => { + for (const extension of ['ts', 'js']) { + const file = join(root, `${page}.${extension}`) + if (existsSync(file)) { + return file + } + } +} + +export const getDependenciesOfFile = async (file: string) => { + const nft = `${file}.nft.json` + if (!existsSync(nft)) { + return [] + } + const dependencies = await readJson(nft, 'utf8') + return dependencies.files.map((dep) => resolve(file, dep)) +} + const baseServerReplacements: Array<[string, string]> = [ [`let ssgCacheKey = `, `let ssgCacheKey = process.env._BYPASS_SSG || `], ] diff --git a/plugin/src/helpers/functions.ts b/plugin/src/helpers/functions.ts index a4696d8257..187d0e9061 100644 --- a/plugin/src/helpers/functions.ts +++ b/plugin/src/helpers/functions.ts @@ -1,29 +1,60 @@ import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' import bridgeFile from '@vercel/node-bridge' -import { copyFile, ensureDir, writeFile, writeJSON } from 'fs-extra' +import { copyFile, ensureDir, readJSON, writeFile, writeJSON } from 'fs-extra' import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config' import { join, relative, resolve } from 'pathe' import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME, DEFAULT_FUNCTIONS_SRC } from '../constants' +import { getApiHandler } from '../templates/getApiHandler' import { getHandler } from '../templates/getHandler' -import { getPageResolver } from '../templates/getPageResolver' +import { getPageResolver, getSinglePageResolver } from '../templates/getPageResolver' + +import { ApiConfig, extractConfigFromFile } from './analysis' +import { getSourceFileForPage } from './files' +import { getFunctionNameForPage } from './utils' + +export interface ApiRouteConfig { + route: string + config: ApiConfig + compiled: string +} export const generateFunctions = async ( { FUNCTIONS_SRC = DEFAULT_FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, PUBLISH_DIR }: NetlifyPluginConstants, appDir: string, + apiRoutes: Array, ): Promise => { - const functionsDir = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC - const functionDir = join(process.cwd(), functionsDir, HANDLER_FUNCTION_NAME) - const publishDir = relative(functionDir, resolve(PUBLISH_DIR)) + const publish = resolve(PUBLISH_DIR) + const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC) + console.log({ functionsDir }) + const functionDir = join(functionsDir, HANDLER_FUNCTION_NAME) + const publishDir = relative(functionDir, publish) - const writeHandler = async (func: string, isODB: boolean) => { + for (const { route, config, compiled } of apiRoutes) { + const apiHandlerSource = await getApiHandler({ page: route, schedule: config.schedule }) + const functionName = getFunctionNameForPage(route, config.background) + await ensureDir(join(functionsDir, functionName)) + await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) + await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) + await copyFile( + join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), + join(functionsDir, functionName, 'handlerUtils.js'), + ) + const resolverSource = await getSinglePageResolver({ + functionsDir, + sourceFile: join(publish, 'server', compiled), + }) + await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource) + } + + const writeHandler = async (functionName: string, isODB: boolean) => { const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) }) - await ensureDir(join(functionsDir, func)) - await writeFile(join(functionsDir, func, `${func}.js`), handlerSource) - await copyFile(bridgeFile, join(functionsDir, func, 'bridge.js')) + await ensureDir(join(functionsDir, functionName)) + await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource) + await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) await copyFile( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), - join(functionsDir, func, 'handlerUtils.js'), + join(functionsDir, functionName, 'handlerUtils.js'), ) } @@ -105,3 +136,15 @@ export const setupImageFunction = async ({ }) } } + +export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise> => { + const pages = await readJSON(join(publish, 'server', 'pages-manifest.json')) + const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/')) + const pagesDir = join(baseDir, 'pages') + return Promise.all( + apiRoutes.map(async (apiRoute) => { + const filePath = getSourceFileForPage(apiRoute, pagesDir) + return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] } + }), + ) +} diff --git a/plugin/src/helpers/redirects.ts b/plugin/src/helpers/redirects.ts index 67090f1d3f..896a451201 100644 --- a/plugin/src/helpers/redirects.ts +++ b/plugin/src/helpers/redirects.ts @@ -10,6 +10,7 @@ import { join } from 'pathe' import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants' import { getMiddleware } from './files' +import { ApiRouteConfig } from './functions' import { RoutesManifest } from './types' import { getApiRewrites, @@ -219,10 +220,12 @@ export const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath, trailingSlash, appDir }, buildId, + apiRoutes, }: { netlifyConfig: NetlifyConfig nextConfig: Pick buildId: string + apiRoutes: Array }) => { const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest = await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json')) @@ -247,7 +250,7 @@ export const generateRedirects = async ({ // This is only used in prod, so dev uses `next dev` directly netlifyConfig.redirects.push( // API routes always need to be served from the regular function - ...getApiRewrites(basePath), + ...getApiRewrites(basePath, apiRoutes), // Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped ...(await getPreviewRewrites({ basePath, appDir })), ) diff --git a/plugin/src/helpers/utils.ts b/plugin/src/helpers/utils.ts index 19e549f7d2..7e83a37779 100644 --- a/plugin/src/helpers/utils.ts +++ b/plugin/src/helpers/utils.ts @@ -1,11 +1,31 @@ +/* eslint-disable max-lines */ import type { NetlifyConfig } from '@netlify/build' import globby from 'globby' import { join } from 'pathe' import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants' +import type { ApiRouteConfig } from './functions' import { I18n } from './types' +const RESERVED_FILENAME = /[^\w_-]/g + +// +// // Replace catch-all, e.g., [...slug] +// .replace(CATCH_ALL_REGEX, '/:$1/*') +// // Replace optional catch-all, e.g., [[...slug]] +// .replace(OPTIONAL_CATCH_ALL_REGEX, '/*') +// // Replace dynamic parameters, e.g., [id] +// .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'), +// + +export const getFunctionNameForPage = (page: string, background = false) => + `${page + .replace(CATCH_ALL_REGEX, '_$1-SPLAT') + .replace(OPTIONAL_CATCH_ALL_REGEX, '-SPLAT') + .replace(DYNAMIC_PARAMETER_REGEX, '_$1-PARAM') + .replace(RESERVED_FILENAME, '_')}-${background ? 'background' : 'handler'}` + export const toNetlifyRoute = (nextRoute: string): Array => { const netlifyRoutes = [nextRoute] @@ -117,18 +137,35 @@ export const redirectsForNextRouteWithData = ({ force, })) -export const getApiRewrites = (basePath) => [ - { - from: `${basePath}/api`, - to: HANDLER_FUNCTION_PATH, - status: 200, - }, - { - from: `${basePath}/api/*`, - to: HANDLER_FUNCTION_PATH, - status: 200, - }, -] +export const getApiRewrites = (basePath: string, apiRoutes: Array) => { + const apiRewrites = apiRoutes.map((apiRoute) => { + const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`) + + // Scheduled functions can't be invoked directly + if (apiRoute.config.schedule) { + return { from, to: '/404.html', status: 404 } + } + return { + from, + to: `/.netlify/functions/${getFunctionNameForPage(apiRoute.route, apiRoute.config.background)}`, + status: 200, + } + }) + + return [ + ...apiRewrites, + { + from: `${basePath}/api`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + { + from: `${basePath}/api/*`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + ] +} export const getPreviewRewrites = async ({ basePath, appDir }) => { const publicFiles = await globby('**/*', { cwd: join(appDir, 'public') }) @@ -185,3 +222,4 @@ export const isNextAuthInstalled = (): boolean => { return false } } +/* eslint-enable max-lines */ diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 6b25aade40..6e8e58f23c 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -17,7 +17,7 @@ import { } from './helpers/config' import { updateConfig, writeEdgeFunctions, loadMiddlewareManifest } from './helpers/edge' import { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } from './helpers/files' -import { generateFunctions, setupImageFunction, generatePagesResolver } from './helpers/functions' +import { generateFunctions, setupImageFunction, generatePagesResolver, getApiRouteConfigs } from './helpers/functions' import { generateRedirects, generateStaticRedirects } from './helpers/redirects' import { shouldSkip, isNextAuthInstalled } from './helpers/utils' import { @@ -106,13 +106,14 @@ const plugin: NetlifyPlugin = { const buildId = readFileSync(join(publish, 'BUILD_ID'), 'utf8').trim() await configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) }) + const apiRoutes = await getApiRouteConfigs(publish, appDir) - await generateFunctions(constants, appDir) + await generateFunctions(constants, appDir, apiRoutes) await generatePagesResolver({ target, constants }) await movePublicFiles({ appDir, outdir, publish }) - await patchNextFiles(basePath) + await patchNextFiles(appDir) if (!process.env.SERVE_STATIC_FILES_FROM_ORIGIN) { await moveStaticPages({ target, netlifyConfig, i18n, basePath }) @@ -135,6 +136,7 @@ const plugin: NetlifyPlugin = { netlifyConfig, nextConfig: { basePath, i18n, trailingSlash, appDir }, buildId, + apiRoutes, }) // We call this even if we don't have edge functions enabled because we still use it for images diff --git a/plugin/src/templates/getApiHandler.ts b/plugin/src/templates/getApiHandler.ts new file mode 100644 index 0000000000..81ee770d64 --- /dev/null +++ b/plugin/src/templates/getApiHandler.ts @@ -0,0 +1,136 @@ +import { HandlerContext, HandlerEvent } from '@netlify/functions' +import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge' +// Aliasing like this means the editor may be able to syntax-highlight the string +import { outdent as javascript } from 'outdent' + +import type { NextConfig } from '../helpers/config' + +import type { NextServerType } from './handlerUtils' + +/* eslint-disable @typescript-eslint/no-var-requires */ + +const { Server } = require('http') +const path = require('path') +// eslint-disable-next-line n/prefer-global/url, n/prefer-global/url-search-params +const { URLSearchParams, URL } = require('url') + +const { Bridge } = require('@vercel/node-bridge/bridge') + +const { getMultiValueHeaders, getNextServer } = require('./handlerUtils') +/* eslint-enable @typescript-eslint/no-var-requires */ + +type Mutable = { + -readonly [K in keyof T]: T[K] +} + +// We return a function and then call `toString()` on it to serialise it as the launcher function + +const makeHandler = (conf: NextConfig, app, pageRoot, page) => { + // Change working directory into the site root, unless using Nx, which moves the + // dist directory and handles this itself + const dir = path.resolve(__dirname, app) + if (pageRoot.startsWith(dir)) { + process.chdir(dir) + } + + // This is just so nft knows about the page entrypoints. It's not actually used + try { + // eslint-disable-next-line n/no-missing-require + require.resolve('./pages.js') + } catch {} + + // React assumes you want development mode if NODE_ENV is unset. + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(process.env as Mutable).NODE_ENV ||= 'production' + + // We don't want to write ISR files to disk in the lambda environment + conf.experimental.isrFlushToDisk = false + // This is our flag that we use when patching the source + // eslint-disable-next-line no-underscore-dangle + process.env._BYPASS_SSG = 'true' + for (const [key, value] of Object.entries(conf.env)) { + process.env[key] = String(value) + } + + // We memoize this because it can be shared between requests, but don't instantiate it until + // the first request because we need the host and port. + let bridge: NodeBridge + const getBridge = (event: HandlerEvent): NodeBridge => { + if (bridge) { + return bridge + } + // Scheduled functions don't have a URL, but we need to give one so Next knows the route to serve + const url = event.rawUrl ? new URL(event.rawUrl) : new URL(path, process.env.URL || 'http://n') + const port = Number.parseInt(url.port) || 80 + + const NextServer: NextServerType = getNextServer() + const nextServer = new NextServer({ + // We know we're just an API route, so can enable minimal mode + minimalMode: true, + conf, + dir, + customServer: false, + hostname: url.hostname, + port, + }) + const requestHandler = nextServer.getRequestHandler() + const server = new Server(async (req, res) => { + try { + await requestHandler(req, res) + } catch (error) { + console.error(error) + throw new Error('Error handling request. See function logs for details.') + } + }) + bridge = new Bridge(server) + bridge.listen() + return bridge + } + + return async function handler(event: HandlerEvent, context: HandlerContext) { + // Ensure that paths are encoded - but don't double-encode them + event.path = event.rawUrl ? new URL(event.rawUrl).pathname : page + // Next expects to be able to parse the query from the URL + const query = new URLSearchParams(event.queryStringParameters).toString() + event.path = query ? `${event.path}?${query}` : event.path + // We know the page + event.headers['x-matched-path'] = page + const { headers, ...result } = await getBridge(event).launcher(event, context) + + // Convert all headers to multiValueHeaders + + const multiValueHeaders = getMultiValueHeaders(headers) + + multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] + console.log(`[${event.httpMethod}] ${event.path} (API)`) + return { + ...result, + multiValueHeaders, + isBase64Encoded: result.encoding === 'base64', + } + } +} + +export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', appDir = '../../..' }): string => + // This is a string, but if you have the right editor plugin it should format as js + javascript/* javascript */ ` + const { Server } = require("http"); + // We copy the file here rather than requiring from the node module + const { Bridge } = require("./bridge"); + const { getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils') + + ${schedule ? `const { schedule } = require("@netlify/functions")` : ''} + + + const { config } = require("${publishDir}/required-server-files.json") + let staticManifest + const path = require("path"); + const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "serverless", "pages")); + exports.handler = ${ + schedule + ? `schedule(${JSON.stringify( + schedule, + )},(${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}));` + : `(${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)});` + } +` diff --git a/plugin/src/templates/getHandler.ts b/plugin/src/templates/getHandler.ts index cac6bc159b..c821697865 100644 --- a/plugin/src/templates/getHandler.ts +++ b/plugin/src/templates/getHandler.ts @@ -1,6 +1,6 @@ import { HandlerContext, HandlerEvent } from '@netlify/functions' -// Aliasing like this means the editor may be able to syntax-highlight the string import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge' +// Aliasing like this means the editor may be able to syntax-highlight the string import { outdent as javascript } from 'outdent' import type { NextConfig } from '../helpers/config' @@ -140,14 +140,14 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDir = '../../..' }): string => // This is a string, but if you have the right editor plugin it should format as js - javascript` + javascript/* javascript */ ` const { Server } = require("http"); const { promises } = require("fs"); // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { augmentFsModule, getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils') - const { builder } = require("@netlify/functions"); + ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") let staticManifest try { diff --git a/plugin/src/templates/getPageResolver.ts b/plugin/src/templates/getPageResolver.ts index 975f4a2c72..df7880580b 100644 --- a/plugin/src/templates/getPageResolver.ts +++ b/plugin/src/templates/getPageResolver.ts @@ -1,10 +1,12 @@ import { posix } from 'path' import { outdent } from 'outdent' +import { relative, resolve } from 'pathe' import slash from 'slash' import glob from 'tiny-glob' import { HANDLER_FUNCTION_NAME } from '../constants' +import { getDependenciesOfFile } from '../helpers/files' // Generate a file full of require.resolve() calls for all the pages in the // build. This is used by the nft bundler to find all the pages. @@ -30,3 +32,26 @@ export const getPageResolver = async ({ publish, target }: { publish: string; ta } ` } + +export const getSinglePageResolver = async ({ + functionsDir, + sourceFile, +}: { + functionsDir: string + sourceFile: string +}) => { + const dependencies = await getDependenciesOfFile(sourceFile) + // We don't need the actual name, just the relative path. + const functionDir = resolve(functionsDir, 'functionName') + + const pageFiles = [sourceFile, ...dependencies] + .map((file) => `require.resolve('${relative(functionDir, file)}')`) + .sort() + + return outdent/* javascript */ ` + // This file is purely to allow nft to know about these pages. + try { + ${pageFiles.join('\n ')} + } catch {} + ` +} diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts index abee481768..53b5b58b0b 100644 --- a/test/analysis.spec.ts +++ b/test/analysis.spec.ts @@ -1,32 +1,42 @@ import { extractConfigFromFile } from '../plugin/src/helpers/analysis' import { resolve } from 'path' +import { getDependenciesOfFile } from '../plugin/src/helpers/files' describe('static source analysis', () => { it('should extract config values from a source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/background.js')) + const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.js')) expect(config).toEqual({ background: true, }) }) it('should extract config values from a TypeScript source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/background.ts')) + const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.ts')) expect(config).toEqual({ background: true, }) }) it('should return an empty config if not defined', async () => { - const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/missing.ts')) + const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/missing.ts')) expect(config).toEqual({}) }) it('should return an empty config if config is invalid', async () => { - const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/invalid.ts')) + const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/invalid.ts')) expect(config).toEqual({}) }) it('should extract schedule values from a source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, './fixtures/analysis/scheduled.ts')) + const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/scheduled.ts')) expect(config).toEqual({ schedule: '@daily', }) }) }) + +describe('dependency tracing', () => { + it('generates dependency list from a source file', async () => { + const dependencies = await getDependenciesOfFile(resolve(__dirname, 'fixtures/analysis/background.js')) + expect(dependencies).toEqual( + ['fixtures/webpack-api-runtime.js', 'package.json'].map((dep) => resolve(__dirname, dep)), + ) + }) +}) diff --git a/test/fixtures/analysis/background.js.nft.json b/test/fixtures/analysis/background.js.nft.json new file mode 100644 index 0000000000..e212381029 --- /dev/null +++ b/test/fixtures/analysis/background.js.nft.json @@ -0,0 +1 @@ +{"version":1,"files":["../../webpack-api-runtime.js","../../../package.json"]} \ No newline at end of file From 57b31b26766bbf2dc501d7d6be71d533ca98e8f1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 Jul 2022 07:44:50 +0100 Subject: [PATCH 03/19] chore: fix syntax of api handlers for better static analysis --- plugin/src/templates/getApiHandler.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/plugin/src/templates/getApiHandler.ts b/plugin/src/templates/getApiHandler.ts index 81ee770d64..8e52aa59c3 100644 --- a/plugin/src/templates/getApiHandler.ts +++ b/plugin/src/templates/getApiHandler.ts @@ -126,11 +126,6 @@ export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', a let staticManifest const path = require("path"); const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "serverless", "pages")); - exports.handler = ${ - schedule - ? `schedule(${JSON.stringify( - schedule, - )},(${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}));` - : `(${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)});` - } + const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}) + exports.handler = ${schedule ? `schedule(${JSON.stringify(schedule)}, handler);` : 'handler'} ` From dc8c3de13327fa36e116232aae8a219b03479aba Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 Jul 2022 15:03:57 +0100 Subject: [PATCH 04/19] fix: broken test --- test/__snapshots__/index.js.snap | 39 ++++++++++++++++++++++++++++++++ test/index.js | 7 +++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index 33c4d61b4c..ef03e91c85 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -10,6 +10,7 @@ exports.resolvePages = () => { require.resolve('../../../.next/server/pages/api/enterPreview.js') require.resolve('../../../.next/server/pages/api/exitPreview.js') require.resolve('../../../.next/server/pages/api/hello-background.js') + require.resolve('../../../.next/server/pages/api/hello-scheduled.js') require.resolve('../../../.next/server/pages/api/hello.js') require.resolve('../../../.next/server/pages/api/shows/[...params].js') require.resolve('../../../.next/server/pages/api/shows/[id].js') @@ -45,6 +46,7 @@ exports.resolvePages = () => { require.resolve('../../../.next/server/pages/api/enterPreview.js') require.resolve('../../../.next/server/pages/api/exitPreview.js') require.resolve('../../../.next/server/pages/api/hello-background.js') + require.resolve('../../../.next/server/pages/api/hello-scheduled.js') require.resolve('../../../.next/server/pages/api/hello.js') require.resolve('../../../.next/server/pages/api/shows/[...params].js') require.resolve('../../../.next/server/pages/api/shows/[id].js') @@ -80,6 +82,7 @@ exports.resolvePages = () => { require.resolve('../../../web/.next/server/pages/api/enterPreview.js') require.resolve('../../../web/.next/server/pages/api/exitPreview.js') require.resolve('../../../web/.next/server/pages/api/hello-background.js') + require.resolve('../../../web/.next/server/pages/api/hello-scheduled.js') require.resolve('../../../web/.next/server/pages/api/hello.js') require.resolve('../../../web/.next/server/pages/api/shows/[...params].js') require.resolve('../../../web/.next/server/pages/api/shows/[id].js') @@ -115,6 +118,7 @@ exports.resolvePages = () => { require.resolve('../../../web/.next/server/pages/api/enterPreview.js') require.resolve('../../../web/.next/server/pages/api/exitPreview.js') require.resolve('../../../web/.next/server/pages/api/hello-background.js') + require.resolve('../../../web/.next/server/pages/api/hello-scheduled.js') require.resolve('../../../web/.next/server/pages/api/hello.js') require.resolve('../../../web/.next/server/pages/api/shows/[...params].js') require.resolve('../../../web/.next/server/pages/api/shows/[id].js') @@ -1094,6 +1098,41 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "from": "/api/enterPreview", + "status": 200, + "to": "/.netlify/functions/_api_enterPreview-handler", + }, + Object { + "from": "/api/exitPreview", + "status": 200, + "to": "/.netlify/functions/_api_exitPreview-handler", + }, + Object { + "from": "/api/hello", + "status": 200, + "to": "/.netlify/functions/_api_hello-handler", + }, + Object { + "from": "/api/hello-background", + "status": 200, + "to": "/.netlify/functions/_api_hello-background-background", + }, + Object { + "from": "/api/hello-scheduled", + "status": 404, + "to": "/404.html", + }, + Object { + "from": "/api/shows/:id", + "status": 200, + "to": "/.netlify/functions/_api_shows_id-PARAM-handler", + }, + Object { + "from": "/api/shows/:params/*", + "status": 200, + "to": "/.netlify/functions/_api_shows_params-SPLAT-handler", + }, Object { "force": false, "from": "/broken-image", diff --git a/test/index.js b/test/index.js index b975526fb1..5c8378fbeb 100644 --- a/test/index.js +++ b/test/index.js @@ -33,8 +33,6 @@ const { } = require('../plugin/src/helpers/config') const { dirname } = require('path') const { getProblematicUserRewrites } = require('../plugin/src/helpers/verification') -const { onPostBuild } = require('../plugin/lib') -const { basePath } = require('../demos/next-i18next/next.config') const chance = new Chance() const FIXTURES_DIR = `${__dirname}/fixtures` @@ -121,6 +119,7 @@ const moveNextDist = async function (dir = '.next') { await stubModules(['next', 'sharp']) await ensureDir(dirname(dir)) await copy(path.join(SAMPLE_PROJECT_DIR, '.next'), path.join(process.cwd(), dir)) + await copy(path.join(SAMPLE_PROJECT_DIR, 'pages'), path.join(process.cwd(), 'pages')) await rewriteAppDir(dir) } @@ -454,8 +453,8 @@ describe('onBuild()', () => { '.next/BUILD_ID', '.next/static/chunks/webpack-middleware*.js', '!.next/server/**/*.js.nft.json', - ".next/static/css/1152424140993be6.css", - ".next/static/css/84099ae0bbc955fa.css", + '.next/static/css/1152424140993be6.css', + '.next/static/css/84099ae0bbc955fa.css', '!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*', `!node_modules/next/dist/server/lib/squoosh/**/*.wasm`, `!node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm`, From 9d1dfdd30c10bfa881220bff7b5793b92f88d715 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 8 Aug 2022 10:38:27 +0100 Subject: [PATCH 05/19] chore: change config shape --- demos/default/pages/api/hello-background.ts | 2 +- demos/default/pages/api/hello-scheduled.js | 1 + plugin/src/helpers/analysis.ts | 86 +++++++++++++++++-- plugin/src/helpers/files.ts | 6 ++ plugin/src/helpers/functions.ts | 10 ++- plugin/src/helpers/utils.ts | 23 +++-- plugin/src/templates/getApiHandler.ts | 22 ++++- plugin/src/templates/getPageResolver.ts | 4 + test/analysis.spec.ts | 53 +++++++++++- test/fixtures/analysis/background-edge.ts | 10 +++ test/fixtures/analysis/background-schedule.ts | 11 +++ test/fixtures/analysis/background.js | 2 +- test/fixtures/analysis/background.ts | 2 +- test/fixtures/analysis/default-schedule.ts | 10 +++ test/fixtures/analysis/missing-schedule.ts | 9 ++ test/fixtures/analysis/scheduled-edge.ts | 11 +++ test/fixtures/analysis/scheduled.js | 1 + test/fixtures/analysis/scheduled.ts | 1 + test/index.js | 28 +++++- 19 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 test/fixtures/analysis/background-edge.ts create mode 100644 test/fixtures/analysis/background-schedule.ts create mode 100644 test/fixtures/analysis/default-schedule.ts create mode 100644 test/fixtures/analysis/missing-schedule.ts create mode 100644 test/fixtures/analysis/scheduled-edge.ts diff --git a/demos/default/pages/api/hello-background.ts b/demos/default/pages/api/hello-background.ts index c50f057f7d..58d17922a7 100644 --- a/demos/default/pages/api/hello-background.ts +++ b/demos/default/pages/api/hello-background.ts @@ -5,5 +5,5 @@ export default (req, res) => { } export const config = { - background: true, + type: 'experimental-background', } diff --git a/demos/default/pages/api/hello-scheduled.js b/demos/default/pages/api/hello-scheduled.js index 23b287034c..e415230508 100644 --- a/demos/default/pages/api/hello-scheduled.js +++ b/demos/default/pages/api/hello-scheduled.js @@ -5,5 +5,6 @@ export default (req, res) => { } export const config = { + type: 'experimental-scheduled', schedule: '@hourly', } diff --git a/plugin/src/helpers/analysis.ts b/plugin/src/helpers/analysis.ts index 3a00f3434f..e7578e356a 100644 --- a/plugin/src/helpers/analysis.ts +++ b/plugin/src/helpers/analysis.ts @@ -4,24 +4,100 @@ import { relative } from 'path' import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value' import { parseModule } from 'next/dist/build/analysis/parse-module' -export interface ApiConfig { - runtime?: 'node' | 'experimental-edge' - background?: boolean - schedule?: string +export interface ApiStandardConfig { + type?: never + runtime?: 'nodejs' | 'experimental-edge' + schedule?: never } +export interface ApiScheduledConfig { + type: 'experimental-scheduled' + runtime?: 'nodejs' + schedule: string +} + +export interface ApiBackgroundConfig { + type: 'experimental-background' + runtime?: 'nodejs' + schedule?: never +} + +export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig + +export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => { + if (config.type === 'experimental-scheduled') { + if (!config.schedule) { + console.error( + `Invalid config value in ${relative( + process.cwd(), + apiFilePath, + )}: schedule is required when type is "experimental-scheduled"`, + ) + return false + } + if ((config as ApiConfig).runtime === 'experimental-edge') { + console.error( + `Invalid config value in ${relative( + process.cwd(), + apiFilePath, + )}: edge runtime is not supported for scheduled functions`, + ) + return false + } + return true + } + + if (!config.type || config.type === 'experimental-background') { + if (config.schedule) { + console.error( + `Invalid config value in ${relative( + process.cwd(), + apiFilePath, + )}: schedule is not allowed unless type is "experimental-scheduled"`, + ) + return false + } + if (config.type && (config as ApiConfig).runtime === 'experimental-edge') { + console.error( + `Invalid config value in ${relative( + process.cwd(), + apiFilePath, + )}: edge runtime is not supported for background functions`, + ) + return false + } + return true + } + console.error( + `Invalid config value in ${relative(process.cwd(), apiFilePath)}: type ${ + (config as ApiConfig).type + } is not supported`, + ) + return false +} + +/** + * Uses Next's swc static analysis to extract the config values from a file. + */ export const extractConfigFromFile = async (apiFilePath: string): Promise => { const fileContent = await fs.promises.readFile(apiFilePath, 'utf8') + // No need to parse if there's no "config" if (!fileContent.includes('config')) { return {} } const ast = await parseModule(apiFilePath, fileContent) + + let config: ApiConfig try { - return extractExportedConstValue(ast, 'config') + config = extractExportedConstValue(ast, 'config') } catch (error) { if (error instanceof UnsupportedValueError) { console.warn(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`) } return {} } + if (validateConfigValue(config, apiFilePath)) { + return config + } + throw new Error(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`) } diff --git a/plugin/src/helpers/files.ts b/plugin/src/helpers/files.ts index f8f247768e..fe739a7cb6 100644 --- a/plugin/src/helpers/files.ts +++ b/plugin/src/helpers/files.ts @@ -325,6 +325,9 @@ const getServerFile = (root: string, includeBase = true) => { return findModuleFromBase({ candidates, paths: [root] }) } +/** + * Find the source file for a given page route + */ export const getSourceFileForPage = (page: string, root: string) => { for (const extension of ['ts', 'js']) { const file = join(root, `${page}.${extension}`) @@ -334,6 +337,9 @@ export const getSourceFileForPage = (page: string, root: string) => { } } +/** + * Reads the node file trace file for a given file, and resolves the dependencies + */ export const getDependenciesOfFile = async (file: string) => { const nft = `${file}.nft.json` if (!existsSync(nft)) { diff --git a/plugin/src/helpers/functions.ts b/plugin/src/helpers/functions.ts index 187d0e9061..9e0947fa9a 100644 --- a/plugin/src/helpers/functions.ts +++ b/plugin/src/helpers/functions.ts @@ -31,8 +31,11 @@ export const generateFunctions = async ( const publishDir = relative(functionDir, publish) for (const { route, config, compiled } of apiRoutes) { - const apiHandlerSource = await getApiHandler({ page: route, schedule: config.schedule }) - const functionName = getFunctionNameForPage(route, config.background) + const apiHandlerSource = await getApiHandler({ + page: route, + config, + }) + const functionName = getFunctionNameForPage(route, config.type === 'experimental-background') await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) @@ -137,6 +140,9 @@ export const setupImageFunction = async ({ } } +/** + * Look for API routes, and extract the config from the source file. + */ export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise> => { const pages = await readJSON(join(publish, 'server', 'pages-manifest.json')) const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/')) diff --git a/plugin/src/helpers/utils.ts b/plugin/src/helpers/utils.ts index 7e83a37779..c07d461825 100644 --- a/plugin/src/helpers/utils.ts +++ b/plugin/src/helpers/utils.ts @@ -10,15 +10,11 @@ import { I18n } from './types' const RESERVED_FILENAME = /[^\w_-]/g -// -// // Replace catch-all, e.g., [...slug] -// .replace(CATCH_ALL_REGEX, '/:$1/*') -// // Replace optional catch-all, e.g., [[...slug]] -// .replace(OPTIONAL_CATCH_ALL_REGEX, '/*') -// // Replace dynamic parameters, e.g., [id] -// .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'), -// - +/** + * Given a Next route, generates a valid Netlify function name. + * If "background" is true then the function name will have `-background` + * appended to it, meaning that it is executed as a background function. + */ export const getFunctionNameForPage = (page: string, background = false) => `${page .replace(CATCH_ALL_REGEX, '_$1-SPLAT') @@ -141,13 +137,16 @@ export const getApiRewrites = (basePath: string, apiRoutes: Array { const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`) - // Scheduled functions can't be invoked directly - if (apiRoute.config.schedule) { + // Scheduled functions can't be invoked directly, so we 404 them. + if (apiRoute.config.type === 'experimental-scheduled') { return { from, to: '/404.html', status: 404 } } return { from, - to: `/.netlify/functions/${getFunctionNameForPage(apiRoute.route, apiRoute.config.background)}`, + to: `/.netlify/functions/${getFunctionNameForPage( + apiRoute.route, + apiRoute.config.type === 'experimental-background', + )}`, status: 200, } }) diff --git a/plugin/src/templates/getApiHandler.ts b/plugin/src/templates/getApiHandler.ts index 8e52aa59c3..b25b73f6d4 100644 --- a/plugin/src/templates/getApiHandler.ts +++ b/plugin/src/templates/getApiHandler.ts @@ -3,6 +3,7 @@ import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge' // Aliasing like this means the editor may be able to syntax-highlight the string import { outdent as javascript } from 'outdent' +import { ApiConfig } from '../helpers/analysis' import type { NextConfig } from '../helpers/config' import type { NextServerType } from './handlerUtils' @@ -111,7 +112,20 @@ const makeHandler = (conf: NextConfig, app, pageRoot, page) => { } } -export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', appDir = '../../..' }): string => +/** + * Handlers for API routes are simpler than page routes, but they each have a separate one + */ +export const getApiHandler = ({ + page, + config, + publishDir = '../../../.next', + appDir = '../../..', +}: { + page: string + config: ApiConfig + publishDir?: string + appDir?: string +}): string => // This is a string, but if you have the right editor plugin it should format as js javascript/* javascript */ ` const { Server } = require("http"); @@ -119,7 +133,7 @@ export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', a const { Bridge } = require("./bridge"); const { getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils') - ${schedule ? `const { schedule } = require("@netlify/functions")` : ''} + ${config.type === 'experimental-scheduled' ? `const { schedule } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") @@ -127,5 +141,7 @@ export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', a const path = require("path"); const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "serverless", "pages")); const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}) - exports.handler = ${schedule ? `schedule(${JSON.stringify(schedule)}, handler);` : 'handler'} + exports.handler = ${ + config.type === 'experimental-scheduled' ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler' + } ` diff --git a/plugin/src/templates/getPageResolver.ts b/plugin/src/templates/getPageResolver.ts index df7880580b..d5bc24cc43 100644 --- a/plugin/src/templates/getPageResolver.ts +++ b/plugin/src/templates/getPageResolver.ts @@ -33,6 +33,10 @@ export const getPageResolver = async ({ publish, target }: { publish: string; ta ` } +/** + * API routes only need the dependencies for a single entrypoint, so we use the + * NFT trace file to get the dependencies. + */ export const getSinglePageResolver = async ({ functionsDir, sourceFile, diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts index 53b5b58b0b..0bc8f62b43 100644 --- a/test/analysis.spec.ts +++ b/test/analysis.spec.ts @@ -2,16 +2,24 @@ import { extractConfigFromFile } from '../plugin/src/helpers/analysis' import { resolve } from 'path' import { getDependenciesOfFile } from '../plugin/src/helpers/files' describe('static source analysis', () => { + beforeEach(() => { + // Spy on console.error + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + // Restore console.error + ;(console.error as jest.Mock).mockRestore() + }) it('should extract config values from a source file', async () => { const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.js')) expect(config).toEqual({ - background: true, + type: 'experimental-background', }) }) it('should extract config values from a TypeScript source file', async () => { const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.ts')) expect(config).toEqual({ - background: true, + type: 'experimental-background', }) }) it('should return an empty config if not defined', async () => { @@ -27,9 +35,50 @@ describe('static source analysis', () => { it('should extract schedule values from a source file', async () => { const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/scheduled.ts')) expect(config).toEqual({ + type: 'experimental-scheduled', schedule: '@daily', }) }) + it('should throw if schedule is provided when type is background', async () => { + await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background-schedule.ts'))).rejects.toThrow( + 'Unsupported config value in test/fixtures/analysis/background-schedule.ts', + ) + expect(console.error).toHaveBeenCalledWith( + `Invalid config value in test/fixtures/analysis/background-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`, + ) + }) + it('should throw if schedule is provided when type is default', async () => { + await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/default-schedule.ts'))).rejects.toThrow( + 'Unsupported config value in test/fixtures/analysis/default-schedule.ts', + ) + expect(console.error).toHaveBeenCalledWith( + `Invalid config value in test/fixtures/analysis/default-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`, + ) + }) + it('should throw if schedule is not provided when type is scheduled', async () => { + await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/missing-schedule.ts'))).rejects.toThrow( + 'Unsupported config value in test/fixtures/analysis/missing-schedule.ts', + ) + expect(console.error).toHaveBeenCalledWith( + `Invalid config value in test/fixtures/analysis/missing-schedule.ts: schedule is required when type is "experimental-scheduled"`, + ) + }) + it('should throw if edge runtime is specified for scheduled functions', async () => { + await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/scheduled-edge.ts'))).rejects.toThrow( + 'Unsupported config value in test/fixtures/analysis/scheduled-edge.ts', + ) + expect(console.error).toHaveBeenCalledWith( + `Invalid config value in test/fixtures/analysis/scheduled-edge.ts: edge runtime is not supported for scheduled functions`, + ) + }) + it('should throw if edge runtime is specified for background functions', async () => { + await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background-edge.ts'))).rejects.toThrow( + 'Unsupported config value in test/fixtures/analysis/background-edge.ts', + ) + expect(console.error).toHaveBeenCalledWith( + `Invalid config value in test/fixtures/analysis/background-edge.ts: edge runtime is not supported for background functions`, + ) + }) }) describe('dependency tracing', () => { diff --git a/test/fixtures/analysis/background-edge.ts b/test/fixtures/analysis/background-edge.ts new file mode 100644 index 0000000000..f60836a8f5 --- /dev/null +++ b/test/fixtures/analysis/background-edge.ts @@ -0,0 +1,10 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + type: 'experimental-background', + runtime: 'experimental-edge', +} diff --git a/test/fixtures/analysis/background-schedule.ts b/test/fixtures/analysis/background-schedule.ts new file mode 100644 index 0000000000..5eb8333ead --- /dev/null +++ b/test/fixtures/analysis/background-schedule.ts @@ -0,0 +1,11 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + type: 'experimental-background', + // INVALID + schedule: '@daily', +} diff --git a/test/fixtures/analysis/background.js b/test/fixtures/analysis/background.js index c50f057f7d..58d17922a7 100644 --- a/test/fixtures/analysis/background.js +++ b/test/fixtures/analysis/background.js @@ -5,5 +5,5 @@ export default (req, res) => { } export const config = { - background: true, + type: 'experimental-background', } diff --git a/test/fixtures/analysis/background.ts b/test/fixtures/analysis/background.ts index c50f057f7d..58d17922a7 100644 --- a/test/fixtures/analysis/background.ts +++ b/test/fixtures/analysis/background.ts @@ -5,5 +5,5 @@ export default (req, res) => { } export const config = { - background: true, + type: 'experimental-background', } diff --git a/test/fixtures/analysis/default-schedule.ts b/test/fixtures/analysis/default-schedule.ts new file mode 100644 index 0000000000..aa32c3ad54 --- /dev/null +++ b/test/fixtures/analysis/default-schedule.ts @@ -0,0 +1,10 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + // INVALID + schedule: '@daily', +} diff --git a/test/fixtures/analysis/missing-schedule.ts b/test/fixtures/analysis/missing-schedule.ts new file mode 100644 index 0000000000..afa40a50be --- /dev/null +++ b/test/fixtures/analysis/missing-schedule.ts @@ -0,0 +1,9 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + type: 'experimental-scheduled', +} diff --git a/test/fixtures/analysis/scheduled-edge.ts b/test/fixtures/analysis/scheduled-edge.ts new file mode 100644 index 0000000000..b984839bec --- /dev/null +++ b/test/fixtures/analysis/scheduled-edge.ts @@ -0,0 +1,11 @@ +export default (req, res) => { + res.setHeader('Content-Type', 'application/json') + res.status(200) + res.json({ message: 'hello world :)' }) +} + +export const config = { + type: 'experimental-scheduled', + schedule: '@daily', + runtime: 'experimental-edge', +} diff --git a/test/fixtures/analysis/scheduled.js b/test/fixtures/analysis/scheduled.js index 23b287034c..e415230508 100644 --- a/test/fixtures/analysis/scheduled.js +++ b/test/fixtures/analysis/scheduled.js @@ -5,5 +5,6 @@ export default (req, res) => { } export const config = { + type: 'experimental-scheduled', schedule: '@hourly', } diff --git a/test/fixtures/analysis/scheduled.ts b/test/fixtures/analysis/scheduled.ts index 86de7d9950..b194bf85a4 100644 --- a/test/fixtures/analysis/scheduled.ts +++ b/test/fixtures/analysis/scheduled.ts @@ -5,5 +5,6 @@ export default (req, res) => { } export const config = { + type: 'experimental-scheduled', schedule: '@daily', } diff --git a/test/index.js b/test/index.js index 5c8378fbeb..ae15dcb577 100644 --- a/test/index.js +++ b/test/index.js @@ -13,7 +13,7 @@ const os = require('os') const cpy = require('cpy') const { dir: getTmpDir } = require('tmp-promise') const { downloadFile } = require('../plugin/src/templates/handlerUtils') - +const { getApiRouteConfigs } = require('../plugin/src/helpers/functions') const plugin = require('../plugin/src') const { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME } = require('../plugin/src/constants') @@ -115,7 +115,7 @@ const rewriteAppDir = async function (dir = '.next') { } // Move .next from sample project to current directory -const moveNextDist = async function (dir = '.next') { +export const moveNextDist = async function (dir = '.next') { await stubModules(['next', 'sharp']) await ensureDir(dirname(dir)) await copy(path.join(SAMPLE_PROJECT_DIR, '.next'), path.join(process.cwd(), dir)) @@ -1475,3 +1475,27 @@ describe('function helpers', () => { }) }) }) + +describe('api route file analysis', () => { + it('extracts correct route configs from source files', async () => { + await moveNextDist() + const configs = await getApiRouteConfigs('.next', process.cwd()) + expect(configs).toEqual([ + { compiled: 'pages/api/enterPreview.js', config: {}, route: '/api/enterPreview' }, + { + compiled: 'pages/api/hello-background.js', + config: { type: 'experimental-background' }, + route: '/api/hello-background', + }, + { compiled: 'pages/api/exitPreview.js', config: {}, route: '/api/exitPreview' }, + { compiled: 'pages/api/shows/[...params].js', config: {}, route: '/api/shows/[...params]' }, + { compiled: 'pages/api/shows/[id].js', config: {}, route: '/api/shows/[id]' }, + { compiled: 'pages/api/hello.js', config: {}, route: '/api/hello' }, + { + compiled: 'pages/api/hello-scheduled.js', + config: { schedule: '@hourly', type: 'experimental-scheduled' }, + route: '/api/hello-scheduled', + }, + ]) + }) +}) From 1786f84cd80bf1696b16f38e9eea1579751372a0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 8 Aug 2022 11:09:07 +0100 Subject: [PATCH 06/19] chore: fix test --- test/index.js | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/test/index.js b/test/index.js index ae15dcb577..7905719c56 100644 --- a/test/index.js +++ b/test/index.js @@ -1480,22 +1480,25 @@ describe('api route file analysis', () => { it('extracts correct route configs from source files', async () => { await moveNextDist() const configs = await getApiRouteConfigs('.next', process.cwd()) - expect(configs).toEqual([ - { compiled: 'pages/api/enterPreview.js', config: {}, route: '/api/enterPreview' }, - { - compiled: 'pages/api/hello-background.js', - config: { type: 'experimental-background' }, - route: '/api/hello-background', - }, - { compiled: 'pages/api/exitPreview.js', config: {}, route: '/api/exitPreview' }, - { compiled: 'pages/api/shows/[...params].js', config: {}, route: '/api/shows/[...params]' }, - { compiled: 'pages/api/shows/[id].js', config: {}, route: '/api/shows/[id]' }, - { compiled: 'pages/api/hello.js', config: {}, route: '/api/hello' }, - { - compiled: 'pages/api/hello-scheduled.js', - config: { schedule: '@hourly', type: 'experimental-scheduled' }, - route: '/api/hello-scheduled', - }, - ]) + // Using a Set means the order doesn't matter + expect(new Set(configs)).toEqual( + new Set([ + { compiled: 'pages/api/enterPreview.js', config: {}, route: '/api/enterPreview' }, + { + compiled: 'pages/api/hello-background.js', + config: { type: 'experimental-background' }, + route: '/api/hello-background', + }, + { compiled: 'pages/api/exitPreview.js', config: {}, route: '/api/exitPreview' }, + { compiled: 'pages/api/shows/[...params].js', config: {}, route: '/api/shows/[...params]' }, + { compiled: 'pages/api/shows/[id].js', config: {}, route: '/api/shows/[id]' }, + { compiled: 'pages/api/hello.js', config: {}, route: '/api/hello' }, + { + compiled: 'pages/api/hello-scheduled.js', + config: { schedule: '@hourly', type: 'experimental-scheduled' }, + route: '/api/hello-scheduled', + }, + ]), + ) }) }) From 8f312f121fc2a35811eea54939682f7baab7c347 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 8 Aug 2022 11:18:44 +0100 Subject: [PATCH 07/19] chore: windows paths :angry: --- plugin/src/helpers/analysis.ts | 2 +- test/analysis.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/src/helpers/analysis.ts b/plugin/src/helpers/analysis.ts index e7578e356a..ab495715d4 100644 --- a/plugin/src/helpers/analysis.ts +++ b/plugin/src/helpers/analysis.ts @@ -1,8 +1,8 @@ import fs from 'fs' -import { relative } from 'path' import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value' import { parseModule } from 'next/dist/build/analysis/parse-module' +import { relative } from 'pathe' export interface ApiStandardConfig { type?: never diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts index 0bc8f62b43..a66bf52708 100644 --- a/test/analysis.spec.ts +++ b/test/analysis.spec.ts @@ -1,5 +1,5 @@ import { extractConfigFromFile } from '../plugin/src/helpers/analysis' -import { resolve } from 'path' +import { resolve } from 'pathe' import { getDependenciesOfFile } from '../plugin/src/helpers/files' describe('static source analysis', () => { beforeEach(() => { From 5f582b20e93464b8e229c9b95251f403286022c3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 8 Aug 2022 11:34:48 +0100 Subject: [PATCH 08/19] chore: add e2e tests for extended API routes --- cypress/integration/default/api.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 cypress/integration/default/api.spec.ts diff --git a/cypress/integration/default/api.spec.ts b/cypress/integration/default/api.spec.ts new file mode 100644 index 0000000000..d2efe676f8 --- /dev/null +++ b/cypress/integration/default/api.spec.ts @@ -0,0 +1,12 @@ +describe('Extended API routes', () => { + it('returns HTTP 202 Accepted for background route', () => { + cy.request('/api/hello-background').then((response) => { + expect(response.status).to.equal(202) + }) + }) + it('returns 404 for scheduled route', () => { + cy.request('/api/hello-scheduled').then((response) => { + expect(response.status).to.equal(404) + }) + }) +}) From 00a80a795ad3156ccfc25e503aeb23b55df6f368 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 19 Aug 2022 11:09:50 +0100 Subject: [PATCH 09/19] chore: fix cypress 404 test --- cypress/integration/default/api.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cypress/integration/default/api.spec.ts b/cypress/integration/default/api.spec.ts index d2efe676f8..4fe80a4f93 100644 --- a/cypress/integration/default/api.spec.ts +++ b/cypress/integration/default/api.spec.ts @@ -4,9 +4,7 @@ describe('Extended API routes', () => { expect(response.status).to.equal(202) }) }) - it('returns 404 for scheduled route', () => { - cy.request('/api/hello-scheduled').then((response) => { - expect(response.status).to.equal(404) - }) + it('correctly returns 404 for scheduled route', () => { + cy.request({ url: '/api/hello-scheduled', failOnStatusCode: false }).its('status').should('equal', 404) }) }) From c26647830aacd714795dec2fb3c9329a98d42eb5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 19 Aug 2022 13:33:04 +0100 Subject: [PATCH 10/19] chore: fix import --- test/analysis.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts index a66bf52708..680252689a 100644 --- a/test/analysis.spec.ts +++ b/test/analysis.spec.ts @@ -1,6 +1,6 @@ -import { extractConfigFromFile } from '../plugin/src/helpers/analysis' +import { extractConfigFromFile } from '../packages/runtime/src/helpers/analysis' import { resolve } from 'pathe' -import { getDependenciesOfFile } from '../plugin/src/helpers/files' +import { getDependenciesOfFile } from '../packages/runtime/src/helpers/files' describe('static source analysis', () => { beforeEach(() => { // Spy on console.error From c1fc1be20026b291457b45881a0c8b1efd76f285 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Sep 2022 11:07:39 +0100 Subject: [PATCH 11/19] chore: lint --- .github/workflows/codeql-analysis.yml | 61 +++++++++++++-------------- .prettierignore | 2 +- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index dcf1462bf3..ca1b1288c5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -9,14 +9,14 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ "main", v3 ] + branches: ['main', v3] pull_request: # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: ['main'] schedule: - cron: '25 21 * * 6' @@ -32,41 +32,40 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.prettierignore b/.prettierignore index 5c8cf6476b..52c69c7596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,6 +22,6 @@ lib tsconfig.json demos/nx-next-monorepo-demo -packages/runtime/CHANGELOG.md +**/CHANGELOG.md packages/runtime/lib packages/runtime/dist-types \ No newline at end of file From bb65d50e94b94049b21e2b7c84d9c0f7940b519e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 23 Sep 2022 11:18:16 +0100 Subject: [PATCH 12/19] chore: remove unused function --- packages/runtime/src/helpers/edge.ts | 7 +------ packages/runtime/src/index.ts | 9 +-------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/runtime/src/helpers/edge.ts b/packages/runtime/src/helpers/edge.ts index 2f6ec0ad40..115d8c4152 100644 --- a/packages/runtime/src/helpers/edge.ts +++ b/packages/runtime/src/helpers/edge.ts @@ -3,7 +3,7 @@ import { promises as fs, existsSync } from 'fs' import { resolve, join } from 'path' import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' -import { copy, copyFile, emptyDir, ensureDir, readJSON, readJson, writeJSON, writeJson } from 'fs-extra' +import { copy, copyFile, emptyDir, ensureDir, readJson, writeJson } from 'fs-extra' import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin' import type { RouteHas } from 'next/dist/lib/load-custom-routes' @@ -246,9 +246,4 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => { await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest) } -export const enableEdgeInNextConfig = async (publish: string) => { - const configFile = join(publish, 'required-server-files.json') - const config = await readJSON(configFile) - await writeJSON(configFile, config) -} /* eslint-enable max-lines */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 0b5a1d927a..eb9bddd4f6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,12 +16,7 @@ import { generateCustomHeaders, } from './helpers/config' import { onPreDev } from './helpers/dev' -import { - enableEdgeInNextConfig, - writeEdgeFunctions, - loadMiddlewareManifest, - cleanupEdgeFunctions, -} from './helpers/edge' +import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge' import { moveStaticPages, movePublicFiles, patchNextFiles } from './helpers/files' import { generateFunctions, setupImageFunction, generatePagesResolver, getApiRouteConfigs } from './helpers/functions' import { generateRedirects, generateStaticRedirects } from './helpers/redirects' @@ -183,8 +178,6 @@ const plugin: NetlifyPlugin = { if (usingEdge) { await writeEdgeFunctions(netlifyConfig) - await enableEdgeInNextConfig(publish) - console.log(outdent` ✨ Deploying middleware and functions to ${greenBright`Netlify Edge Functions`} ✨ This feature is in beta. Please share your feedback here: https://ntl.fyi/next-netlify-edge From 6aad6b173bdd4ce91c1827fafed0135ee0706aa7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 6 Oct 2022 09:22:38 +0100 Subject: [PATCH 13/19] chore: use enum for api route type --- package-lock.json | 65 ++++++++----------- packages/runtime/src/helpers/analysis.ts | 29 +++++---- packages/runtime/src/helpers/utils.ts | 10 +-- .../runtime/src/templates/getApiHandler.ts | 6 +- 4 files changed, 49 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84ebea260c..e89f00fc82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5556,13 +5556,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { "version": "17.0.50", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.50.tgz", "integrity": "sha512-ZCBHzpDb5skMnc1zFXAXnL3l1FAdi+xZvwxK+PkglMmBrwjpp9nKaWuEvrGnSifCJmBFGxZOOFuwC6KH/s0NuA==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5588,7 +5588,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", @@ -9128,7 +9128,7 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "devOptional": true + "dev": true }, "node_modules/custom-routes": { "resolved": "demos/custom-routes", @@ -13154,7 +13154,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==", - "devOptional": true + "dev": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -20324,7 +20324,7 @@ "version": "1.50.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.50.1.tgz", "integrity": "sha512-noTnY41KnlW2A9P8sdwESpDmo+KBNkukI1i8+hOK3footBUcohNHtdOJbckp46XO95nuvcHDDZ+4tmOnpK3hjw==", - "devOptional": true, + "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -23326,7 +23326,7 @@ }, "packages/runtime": { "name": "@netlify/plugin-nextjs", - "version": "4.24.0", + "version": "4.24.1", "license": "MIT", "dependencies": { "@netlify/esbuild": "0.14.39", @@ -25834,8 +25834,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "dev": true, - "requires": {} + "dev": true }, "chalk": { "version": "5.0.1", @@ -26186,8 +26185,7 @@ "version": "17.0.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-typescript": { "version": "3.3.0", @@ -27101,13 +27099,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "@types/react": { "version": "17.0.50", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.50.tgz", "integrity": "sha512-ZCBHzpDb5skMnc1zFXAXnL3l1FAdi+xZvwxK+PkglMmBrwjpp9nKaWuEvrGnSifCJmBFGxZOOFuwC6KH/s0NuA==", - "devOptional": true, + "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -27133,7 +27131,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "@types/sinonjs__fake-timers": { "version": "8.1.1", @@ -27441,8 +27439,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -29803,7 +29800,7 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "devOptional": true + "dev": true }, "custom-routes": { "version": "file:demos/custom-routes", @@ -30933,8 +30930,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-formatter-codeframe": { "version": "7.32.1", @@ -31379,8 +31375,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.1.tgz", "integrity": "sha512-uM4Tgo5u3UWQiroOyDEsYcVMOo7re3zmno0IZmB5auxoaQNIceAbXEkSt8RNrKtaYehARHG06pYK6K1JhtP0Zw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react": { "version": "7.29.4", @@ -31428,8 +31423,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.5.0.tgz", "integrity": "sha512-8k1gRt7D7h03kd+SAAlzXkQwWK22BnK6GKZG+FJA6BAGy22CFvl8kCIXKpVux0cCxMWDQUPqSok0LKaZ0aOcCw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-unicorn": { "version": "43.0.2", @@ -32902,7 +32896,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==", - "devOptional": true + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -33964,8 +33958,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "27.5.1", @@ -38387,7 +38380,7 @@ "version": "1.50.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.50.1.tgz", "integrity": "sha512-noTnY41KnlW2A9P8sdwESpDmo+KBNkukI1i8+hOK3footBUcohNHtdOJbckp46XO95nuvcHDDZ+4tmOnpK3hjw==", - "devOptional": true, + "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -38648,14 +38641,12 @@ "styled-jsx": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.2.tgz", - "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==", - "requires": {} + "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==" }, "use-sync-external-store": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", - "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", - "requires": {} + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==" } } }, @@ -39467,8 +39458,7 @@ "styled-jsx": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.6.tgz", - "integrity": "sha512-xOeROtkK5MGMDimBQ3J6iPId8q0t/BDoG5XN6oKkZClVz9ISF/hihN8OCn2LggMU6N32aXnrXBdn3auSqNS9fA==", - "requires": {} + "integrity": "sha512-xOeROtkK5MGMDimBQ3J6iPId8q0t/BDoG5XN6oKkZClVz9ISF/hihN8OCn2LggMU6N32aXnrXBdn3auSqNS9fA==" }, "supports-color": { "version": "9.2.2", @@ -40190,8 +40180,7 @@ "ws": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "requires": {} + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==" } } }, @@ -40299,8 +40288,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util-deprecate": { "version": "1.0.2", @@ -40705,8 +40693,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "dev": true, - "requires": {} + "dev": true }, "xdg-basedir": { "version": "4.0.0", diff --git a/packages/runtime/src/helpers/analysis.ts b/packages/runtime/src/helpers/analysis.ts index ab495715d4..25e81ab84f 100644 --- a/packages/runtime/src/helpers/analysis.ts +++ b/packages/runtime/src/helpers/analysis.ts @@ -4,6 +4,13 @@ import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/buil import { parseModule } from 'next/dist/build/analysis/parse-module' import { relative } from 'pathe' +// I have no idea what eslint is up to here but it gives an error +// eslint-disable-next-line no-shadow +export enum ApiRouteType { + SCHEDULED = 'experimental-scheduled', + BACKGROUND = 'experimental-background', +} + export interface ApiStandardConfig { type?: never runtime?: 'nodejs' | 'experimental-edge' @@ -11,13 +18,13 @@ export interface ApiStandardConfig { } export interface ApiScheduledConfig { - type: 'experimental-scheduled' + type: ApiRouteType.SCHEDULED runtime?: 'nodejs' schedule: string } export interface ApiBackgroundConfig { - type: 'experimental-background' + type: ApiRouteType.BACKGROUND runtime?: 'nodejs' schedule?: never } @@ -25,13 +32,12 @@ export interface ApiBackgroundConfig { export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => { - if (config.type === 'experimental-scheduled') { + if (config.type === ApiRouteType.SCHEDULED) { if (!config.schedule) { console.error( - `Invalid config value in ${relative( - process.cwd(), - apiFilePath, - )}: schedule is required when type is "experimental-scheduled"`, + `Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is required when type is "${ + ApiRouteType.SCHEDULED + }"`, ) return false } @@ -47,13 +53,12 @@ export const validateConfigValue = (config: ApiConfig, apiFilePath: string): con return true } - if (!config.type || config.type === 'experimental-background') { + if (!config.type || config.type === ApiRouteType.BACKGROUND) { if (config.schedule) { console.error( - `Invalid config value in ${relative( - process.cwd(), - apiFilePath, - )}: schedule is not allowed unless type is "experimental-scheduled"`, + `Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is not allowed unless type is "${ + ApiRouteType.SCHEDULED + }"`, ) return false } diff --git a/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 390bfd28e2..527409c8fb 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -8,6 +8,7 @@ import { join } from 'pathe' import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants' +import { ApiRouteType } from './analysis' import type { ApiRouteConfig } from './functions' import { I18n } from './types' @@ -148,14 +149,14 @@ export const getApiRewrites = (basePath: string, apiRoutes: Array Date: Thu, 6 Oct 2022 09:43:39 +0100 Subject: [PATCH 14/19] chore: snapidoo --- test/__snapshots__/index.js.snap | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index 4c7fe08d73..a38c10e18f 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -1086,11 +1086,6 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, - Object { - "from": "/api", - "status": 200, - "to": "/.netlify/functions/___netlify-handler", - }, Object { "from": "/api/*", "status": 200, From 56d5a061e3863386acf3f063a9c9b37705a2ee5c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 6 Oct 2022 10:02:32 +0100 Subject: [PATCH 15/19] chore: lockfile --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4e2ab75221..e46d3e6d2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23326,7 +23326,7 @@ }, "packages/runtime": { "name": "@netlify/plugin-nextjs", - "version": "4.24.1", + "version": "4.24.2", "license": "MIT", "dependencies": { "@netlify/esbuild": "0.14.39", From 0d3152ff6e72303ccbeb8ba1348dfb2c205b062c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 6 Oct 2022 10:28:38 +0100 Subject: [PATCH 16/19] chore: debug preview test --- cypress/integration/default/preview.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cypress/integration/default/preview.spec.ts b/cypress/integration/default/preview.spec.ts index f31ce588a0..839c716744 100644 --- a/cypress/integration/default/preview.spec.ts +++ b/cypress/integration/default/preview.spec.ts @@ -1,8 +1,10 @@ describe('Preview Mode', () => { it('enters and exits preview mode', () => { + Cypress.Cookies.debug(true) + cy.getCookies().then((cookie) => cy.log('cookies', cookie)) // preview mode is off by default cy.visit('/previewTest') - cy.findByText('Is preview? No', {selector: 'h1'}) + cy.findByText('Is preview? No', { selector: 'h1' }) // enter preview mode cy.request('/api/enterPreview').then((response) => { From f83d562a6a35589ae273f7f49d531887b36248aa Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 6 Oct 2022 16:07:52 +0100 Subject: [PATCH 17/19] chore: use const enum --- packages/runtime/src/helpers/analysis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/helpers/analysis.ts b/packages/runtime/src/helpers/analysis.ts index 25e81ab84f..65e7934fa6 100644 --- a/packages/runtime/src/helpers/analysis.ts +++ b/packages/runtime/src/helpers/analysis.ts @@ -6,7 +6,7 @@ import { relative } from 'pathe' // I have no idea what eslint is up to here but it gives an error // eslint-disable-next-line no-shadow -export enum ApiRouteType { +export const enum ApiRouteType { SCHEDULED = 'experimental-scheduled', BACKGROUND = 'experimental-background', } From 898a8a7847996ad7ebfe1ea169d00c81a778a828 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 7 Oct 2022 10:39:07 +0100 Subject: [PATCH 18/19] feat: add warning logs for advanced api routes --- packages/runtime/src/helpers/functions.ts | 49 +++++++++++++++++++++-- packages/runtime/src/index.ts | 9 ++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index 74bade2fb6..dd95bc2046 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -1,8 +1,11 @@ +/* eslint-disable max-lines */ import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' import bridgeFile from '@vercel/node-bridge' +import chalk from 'chalk' import destr from 'destr' -import { copyFile, ensureDir, readJSON, writeFile, writeJSON } from 'fs-extra' +import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON } from 'fs-extra' import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config' +import { outdent } from 'outdent' import { join, relative, resolve } from 'pathe' import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME, DEFAULT_FUNCTIONS_SRC } from '../constants' @@ -10,7 +13,7 @@ import { getApiHandler } from '../templates/getApiHandler' import { getHandler } from '../templates/getHandler' import { getPageResolver, getSinglePageResolver } from '../templates/getPageResolver' -import { ApiConfig, extractConfigFromFile } from './analysis' +import { ApiConfig, ApiRouteType, extractConfigFromFile } from './analysis' import { getSourceFileForPage } from './files' import { getFunctionNameForPage } from './utils' @@ -36,7 +39,7 @@ export const generateFunctions = async ( page: route, config, }) - const functionName = getFunctionNameForPage(route, config.type === 'experimental-background') + const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) await ensureDir(join(functionsDir, functionName)) await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource) await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js')) @@ -173,3 +176,43 @@ export const getApiRouteConfigs = async (publish: string, baseDir: string): Prom }), ) } + +interface FunctionsManifest { + functions: Array<{ name: string; schedule?: string }> +} + +/** + * Warn the user of the caveats if they're using background or scheduled API routes + */ + +export const warnOnApiRoutes = async ({ + FUNCTIONS_DIST, +}: Pick): Promise => { + const functionsManifestPath = join(FUNCTIONS_DIST, 'manifest.json') + if (!existsSync(functionsManifestPath)) { + return + } + + const { functions }: FunctionsManifest = await readJSON(functionsManifestPath) + + if (functions.some((func) => func.name.endsWith('-background'))) { + console.warn( + outdent` + ${chalk.yellowBright`Using background API routes`} + If your account type does not support background functions, the deploy will fail. + During local development, background API routes will run as regular API routes, but in production they will immediately return an empty "202 Accepted" response. + `, + ) + } + + if (functions.some((func) => func.schedule)) { + console.warn( + outdent` + ${chalk.yellowBright`Using scheduled API routes`} + These are run on a schedule when deployed to production. + You can test them locally by loading them in your browser but this will not be available when deployed, and any returned value is ignored. + `, + ) + } +} +/* eslint-enable max-lines */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 716aaceca0..37f67f6aa4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -19,7 +19,13 @@ import { import { onPreDev } from './helpers/dev' import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge' import { moveStaticPages, movePublicFiles, patchNextFiles } from './helpers/files' -import { generateFunctions, setupImageFunction, generatePagesResolver, getApiRouteConfigs } from './helpers/functions' +import { + generateFunctions, + setupImageFunction, + generatePagesResolver, + getApiRouteConfigs, + warnOnApiRoutes, +} from './helpers/functions' import { generateRedirects, generateStaticRedirects } from './helpers/redirects' import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders, getRemotePatterns } from './helpers/utils' import { @@ -217,6 +223,7 @@ const plugin: NetlifyPlugin = { warnForProblematicUserRewrites({ basePath, redirects }) warnForRootRedirects({ appDir }) + await warnOnApiRoutes({ FUNCTIONS_DIST }) }, } // The types haven't been updated yet From 781be0cee758beb3436efc2eef3fd2cf1c014078 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 17 Oct 2022 17:32:10 +0100 Subject: [PATCH 19/19] chore: support jsx --- packages/runtime/src/helpers/analysis.ts | 5 ++++- packages/runtime/src/helpers/files.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/helpers/analysis.ts b/packages/runtime/src/helpers/analysis.ts index 65e7934fa6..6cc2f985de 100644 --- a/packages/runtime/src/helpers/analysis.ts +++ b/packages/runtime/src/helpers/analysis.ts @@ -1,4 +1,4 @@ -import fs from 'fs' +import fs, { existsSync } from 'fs' import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value' import { parseModule } from 'next/dist/build/analysis/parse-module' @@ -85,6 +85,9 @@ export const validateConfigValue = (config: ApiConfig, apiFilePath: string): con * Uses Next's swc static analysis to extract the config values from a file. */ export const extractConfigFromFile = async (apiFilePath: string): Promise => { + if (!apiFilePath || !existsSync(apiFilePath)) { + return {} + } const fileContent = await fs.promises.readFile(apiFilePath, 'utf8') // No need to parse if there's no "config" if (!fileContent.includes('config')) { diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts index c577147547..400e9ee246 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/src/helpers/files.ts @@ -18,6 +18,7 @@ import { Rewrites, RoutesManifest } from './types' import { findModuleFromBase } from './utils' const TEST_ROUTE = /(|\/)\[[^/]+?](\/|\.html|$)/ +const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] export const isDynamicRoute = (route) => TEST_ROUTE.test(route) @@ -337,12 +338,13 @@ const getServerFile = (root: string, includeBase = true) => { * Find the source file for a given page route */ export const getSourceFileForPage = (page: string, root: string) => { - for (const extension of ['ts', 'js']) { + for (const extension of SOURCE_FILE_EXTENSIONS) { const file = join(root, `${page}.${extension}`) if (existsSync(file)) { return file } } + console.log('Could not find source file for page', page) } /**