diff --git a/cypress/integration/default/api.spec.ts b/cypress/integration/default/api.spec.ts new file mode 100644 index 0000000000..4fe80a4f93 --- /dev/null +++ b/cypress/integration/default/api.spec.ts @@ -0,0 +1,10 @@ +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('correctly returns 404 for scheduled route', () => { + cy.request({ url: '/api/hello-scheduled', failOnStatusCode: false }).its('status').should('equal', 404) + }) +}) 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) => { diff --git a/demos/default/pages/api/hello-background.ts b/demos/default/pages/api/hello-background.ts new file mode 100644 index 0000000000..58d17922a7 --- /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 = { + type: 'experimental-background', +} diff --git a/demos/default/pages/api/hello-scheduled.js b/demos/default/pages/api/hello-scheduled.js new file mode 100644 index 0000000000..e415230508 --- /dev/null +++ b/demos/default/pages/api/hello-scheduled.js @@ -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-scheduled', + schedule: '@hourly', +} diff --git a/packages/runtime/src/helpers/analysis.ts b/packages/runtime/src/helpers/analysis.ts new file mode 100644 index 0000000000..6cc2f985de --- /dev/null +++ b/packages/runtime/src/helpers/analysis.ts @@ -0,0 +1,111 @@ +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' +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 const enum ApiRouteType { + SCHEDULED = 'experimental-scheduled', + BACKGROUND = 'experimental-background', +} + +export interface ApiStandardConfig { + type?: never + runtime?: 'nodejs' | 'experimental-edge' + schedule?: never +} + +export interface ApiScheduledConfig { + type: ApiRouteType.SCHEDULED + runtime?: 'nodejs' + schedule: string +} + +export interface ApiBackgroundConfig { + type: ApiRouteType.BACKGROUND + runtime?: 'nodejs' + schedule?: never +} + +export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig + +export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => { + if (config.type === ApiRouteType.SCHEDULED) { + if (!config.schedule) { + console.error( + `Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is required when type is "${ + ApiRouteType.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 === ApiRouteType.BACKGROUND) { + if (config.schedule) { + console.error( + `Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is not allowed unless type is "${ + ApiRouteType.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 => { + 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')) { + return {} + } + const ast = await parseModule(apiFilePath, fileContent) + + let config: ApiConfig + try { + 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/packages/runtime/src/helpers/config.ts b/packages/runtime/src/helpers/config.ts index 33471bc564..64984297d5 100644 --- a/packages/runtime/src/helpers/config.ts +++ b/packages/runtime/src/helpers/config.ts @@ -92,7 +92,7 @@ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore } /* 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/packages/runtime/src/helpers/edge.ts b/packages/runtime/src/helpers/edge.ts index 949c493ec7..ad84dd3d88 100644 --- a/packages/runtime/src/helpers/edge.ts +++ b/packages/runtime/src/helpers/edge.ts @@ -5,7 +5,7 @@ import { resolve, join } from 'path' import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' import { greenBright } from 'chalk' import destr from 'destr' -import { copy, copyFile, emptyDir, ensureDir, readJSON, readJson, writeJSON, writeJson } from 'fs-extra' +import { copy, copyFile, emptyDir, ensureDir, readJson, writeJSON, 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' import { outdent } from 'outdent' @@ -284,9 +284,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/helpers/files.ts b/packages/runtime/src/helpers/files.ts index 11e7b0076d..400e9ee246 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/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' @@ -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) @@ -333,6 +334,31 @@ 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 SOURCE_FILE_EXTENSIONS) { + const file = join(root, `${page}.${extension}`) + if (existsSync(file)) { + return file + } + } + console.log('Could not find source file for page', page) +} + +/** + * 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)) { + return [] + } + const dependencies = await readJson(nft, 'utf8') + return dependencies.files.map((dep) => resolve(file, dep)) +} + const baseServerReplacements: Array<[string, string]> = [ // force manual revalidate during cache fetches [ diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index e0bbfa5d47..dd95bc2046 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -1,30 +1,67 @@ +/* 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, 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' +import { getApiHandler } from '../templates/getApiHandler' import { getHandler } from '../templates/getHandler' -import { getPageResolver } from '../templates/getPageResolver' +import { getPageResolver, getSinglePageResolver } from '../templates/getPageResolver' + +import { ApiConfig, ApiRouteType, 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, + config, + }) + 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')) + 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'), ) } @@ -124,3 +161,58 @@ 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/')) + 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] } + }), + ) +} + +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/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts index 2e950e807e..764117596e 100644 --- a/packages/runtime/src/helpers/redirects.ts +++ b/packages/runtime/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, @@ -228,10 +229,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')) @@ -249,7 +252,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/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 5a3525c4b5..527409c8fb 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -8,8 +8,24 @@ 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' +const RESERVED_FILENAME = /[^\w_-]/g + +/** + * 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') + .replace(OPTIONAL_CATCH_ALL_REGEX, '-SPLAT') + .replace(DYNAMIC_PARAMETER_REGEX, '_$1-PARAM') + .replace(RESERVED_FILENAME, '_')}-${background ? 'background' : 'handler'}` + type ExperimentalConfigWithLegacy = ExperimentalConfig & { images?: Pick } @@ -128,18 +144,33 @@ 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, so we 404 them. + if (apiRoute.config.type === ApiRouteType.SCHEDULED) { + return { from, to: '/404.html', status: 404 } + } + return { + from, + to: `/.netlify/functions/${getFunctionNameForPage( + apiRoute.route, + apiRoute.config.type === ApiRouteType.BACKGROUND, + )}`, + status: 200, + } + }) + + return [ + ...apiRewrites, + { + from: `${basePath}/api/*`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + ] +} export const getPreviewRewrites = async ({ basePath, appDir }) => { const publicFiles = await globby('**/*', { cwd: join(appDir, 'public') }) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7f53c1386f..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 } 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 { @@ -142,13 +148,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 (!destr(process.env.SERVE_STATIC_FILES_FROM_ORIGIN)) { await moveStaticPages({ target, netlifyConfig, i18n, basePath }) @@ -172,6 +179,7 @@ const plugin: NetlifyPlugin = { netlifyConfig, nextConfig: { basePath, i18n, trailingSlash, appDir }, buildId, + apiRoutes, }) await writeEdgeFunctions(netlifyConfig) @@ -215,6 +223,7 @@ const plugin: NetlifyPlugin = { warnForProblematicUserRewrites({ basePath, redirects }) warnForRootRedirects({ appDir }) + await warnOnApiRoutes({ FUNCTIONS_DIST }) }, } // The types haven't been updated yet diff --git a/packages/runtime/src/templates/getApiHandler.ts b/packages/runtime/src/templates/getApiHandler.ts new file mode 100644 index 0000000000..f65fe9cca3 --- /dev/null +++ b/packages/runtime/src/templates/getApiHandler.ts @@ -0,0 +1,147 @@ +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 { ApiConfig, ApiRouteType } from '../helpers/analysis' +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. + + ;(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', + } + } +} + +/** + * 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"); + // We copy the file here rather than requiring from the node module + const { Bridge } = require("./bridge"); + const { getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils') + + ${config.type === ApiRouteType.SCHEDULED ? `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")); + const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}) + exports.handler = ${ + config.type === ApiRouteType.SCHEDULED ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler' + } +` diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index 37642e50bc..5eacd64ecb 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/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' @@ -156,14 +156,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/packages/runtime/src/templates/getPageResolver.ts b/packages/runtime/src/templates/getPageResolver.ts index 975f4a2c72..d5bc24cc43 100644 --- a/packages/runtime/src/templates/getPageResolver.ts +++ b/packages/runtime/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,30 @@ 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, +}: { + 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/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index 5256ac3769..a38c10e18f 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') @@ -46,6 +47,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') @@ -82,6 +84,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') @@ -118,6 +121,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') @@ -1083,14 +1087,44 @@ Array [ "to": "/.netlify/functions/___netlify-handler", }, Object { - "from": "/api", + "from": "/api/*", "status": 200, "to": "/.netlify/functions/___netlify-handler", }, Object { - "from": "/api/*", + "from": "/api/enterPreview", "status": 200, - "to": "/.netlify/functions/___netlify-handler", + "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, diff --git a/test/analysis.spec.ts b/test/analysis.spec.ts new file mode 100644 index 0000000000..680252689a --- /dev/null +++ b/test/analysis.spec.ts @@ -0,0 +1,91 @@ +import { extractConfigFromFile } from '../packages/runtime/src/helpers/analysis' +import { resolve } from 'pathe' +import { getDependenciesOfFile } from '../packages/runtime/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({ + 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({ + type: 'experimental-background', + }) + }) + 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({ + 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', () => { + 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-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 new file mode 100644 index 0000000000..58d17922a7 --- /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 = { + type: 'experimental-background', +} 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 diff --git a/test/fixtures/analysis/background.ts b/test/fixtures/analysis/background.ts new file mode 100644 index 0000000000..58d17922a7 --- /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 = { + 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/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/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/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-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 new file mode 100644 index 0000000000..e415230508 --- /dev/null +++ b/test/fixtures/analysis/scheduled.js @@ -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-scheduled', + schedule: '@hourly', +} diff --git a/test/fixtures/analysis/scheduled.ts b/test/fixtures/analysis/scheduled.ts new file mode 100644 index 0000000000..b194bf85a4 --- /dev/null +++ b/test/fixtures/analysis/scheduled.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-scheduled', + schedule: '@daily', +} diff --git a/test/index.js b/test/index.js index c3046db7bd..1a94c77fe6 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('../packages/runtime/src/templates/handlerUtils') - +const { getApiRouteConfigs } = require('../packages/runtime/src/helpers/functions') const nextRuntimeFactory = require('../packages/runtime/src') const nextRuntime = nextRuntimeFactory({}) @@ -116,10 +116,11 @@ 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)) + await copy(path.join(SAMPLE_PROJECT_DIR, 'pages'), path.join(process.cwd(), 'pages')) await rewriteAppDir(dir) } @@ -1562,3 +1563,30 @@ 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()) + // 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', + }, + ]), + ) + }) +})