Skip to content

Commit 654c6de

Browse files
authored
feat: split api routes into separate functions (#1495)
* feat: add static analysis helper * feat: split api routes into separate functions * chore: fix syntax of api handlers for better static analysis * fix: broken test * chore: change config shape * chore: fix test * chore: windows paths 😠 * chore: add e2e tests for extended API routes * chore: fix cypress 404 test * chore: fix import * chore: lint * chore: remove unused function * chore: use enum for api route type * chore: snapidoo * chore: lockfile * chore: debug preview test * chore: use const enum * feat: add warning logs for advanced api routes * chore: support jsx
1 parent 05c24d2 commit 654c6de

30 files changed

+771
-43
lines changed
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
describe('Extended API routes', () => {
2+
it('returns HTTP 202 Accepted for background route', () => {
3+
cy.request('/api/hello-background').then((response) => {
4+
expect(response.status).to.equal(202)
5+
})
6+
})
7+
it('correctly returns 404 for scheduled route', () => {
8+
cy.request({ url: '/api/hello-scheduled', failOnStatusCode: false }).its('status').should('equal', 404)
9+
})
10+
})

cypress/integration/default/preview.spec.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
describe('Preview Mode', () => {
22
it('enters and exits preview mode', () => {
3+
Cypress.Cookies.debug(true)
4+
cy.getCookies().then((cookie) => cy.log('cookies', cookie))
35
// preview mode is off by default
46
cy.visit('/previewTest')
5-
cy.findByText('Is preview? No', {selector: 'h1'})
7+
cy.findByText('Is preview? No', { selector: 'h1' })
68

79
// enter preview mode
810
cy.request('/api/enterPreview').then((response) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
type: 'experimental-background',
9+
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
type: 'experimental-scheduled',
9+
schedule: '@hourly',
10+
}
+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import fs, { existsSync } from 'fs'
2+
3+
import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value'
4+
import { parseModule } from 'next/dist/build/analysis/parse-module'
5+
import { relative } from 'pathe'
6+
7+
// I have no idea what eslint is up to here but it gives an error
8+
// eslint-disable-next-line no-shadow
9+
export const enum ApiRouteType {
10+
SCHEDULED = 'experimental-scheduled',
11+
BACKGROUND = 'experimental-background',
12+
}
13+
14+
export interface ApiStandardConfig {
15+
type?: never
16+
runtime?: 'nodejs' | 'experimental-edge'
17+
schedule?: never
18+
}
19+
20+
export interface ApiScheduledConfig {
21+
type: ApiRouteType.SCHEDULED
22+
runtime?: 'nodejs'
23+
schedule: string
24+
}
25+
26+
export interface ApiBackgroundConfig {
27+
type: ApiRouteType.BACKGROUND
28+
runtime?: 'nodejs'
29+
schedule?: never
30+
}
31+
32+
export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig
33+
34+
export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => {
35+
if (config.type === ApiRouteType.SCHEDULED) {
36+
if (!config.schedule) {
37+
console.error(
38+
`Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is required when type is "${
39+
ApiRouteType.SCHEDULED
40+
}"`,
41+
)
42+
return false
43+
}
44+
if ((config as ApiConfig).runtime === 'experimental-edge') {
45+
console.error(
46+
`Invalid config value in ${relative(
47+
process.cwd(),
48+
apiFilePath,
49+
)}: edge runtime is not supported for scheduled functions`,
50+
)
51+
return false
52+
}
53+
return true
54+
}
55+
56+
if (!config.type || config.type === ApiRouteType.BACKGROUND) {
57+
if (config.schedule) {
58+
console.error(
59+
`Invalid config value in ${relative(process.cwd(), apiFilePath)}: schedule is not allowed unless type is "${
60+
ApiRouteType.SCHEDULED
61+
}"`,
62+
)
63+
return false
64+
}
65+
if (config.type && (config as ApiConfig).runtime === 'experimental-edge') {
66+
console.error(
67+
`Invalid config value in ${relative(
68+
process.cwd(),
69+
apiFilePath,
70+
)}: edge runtime is not supported for background functions`,
71+
)
72+
return false
73+
}
74+
return true
75+
}
76+
console.error(
77+
`Invalid config value in ${relative(process.cwd(), apiFilePath)}: type ${
78+
(config as ApiConfig).type
79+
} is not supported`,
80+
)
81+
return false
82+
}
83+
84+
/**
85+
* Uses Next's swc static analysis to extract the config values from a file.
86+
*/
87+
export const extractConfigFromFile = async (apiFilePath: string): Promise<ApiConfig> => {
88+
if (!apiFilePath || !existsSync(apiFilePath)) {
89+
return {}
90+
}
91+
const fileContent = await fs.promises.readFile(apiFilePath, 'utf8')
92+
// No need to parse if there's no "config"
93+
if (!fileContent.includes('config')) {
94+
return {}
95+
}
96+
const ast = await parseModule(apiFilePath, fileContent)
97+
98+
let config: ApiConfig
99+
try {
100+
config = extractExportedConstValue(ast, 'config')
101+
} catch (error) {
102+
if (error instanceof UnsupportedValueError) {
103+
console.warn(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
104+
}
105+
return {}
106+
}
107+
if (validateConfigValue(config, apiFilePath)) {
108+
return config
109+
}
110+
throw new Error(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
111+
}

packages/runtime/src/helpers/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore
9292
}
9393

9494
/* eslint-enable no-underscore-dangle */
95-
;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME].forEach((functionName) => {
95+
;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'].forEach((functionName) => {
9696
netlifyConfig.functions[functionName] ||= { included_files: [], external_node_modules: [] }
9797
netlifyConfig.functions[functionName].node_bundler = 'nft'
9898
netlifyConfig.functions[functionName].included_files ||= []

packages/runtime/src/helpers/edge.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resolve, join } from 'path'
55
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build'
66
import { greenBright } from 'chalk'
77
import destr from 'destr'
8-
import { copy, copyFile, emptyDir, ensureDir, readJSON, readJson, writeJSON, writeJson } from 'fs-extra'
8+
import { copy, copyFile, emptyDir, ensureDir, readJson, writeJSON, writeJson } from 'fs-extra'
99
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin'
1010
import type { RouteHas } from 'next/dist/lib/load-custom-routes'
1111
import { outdent } from 'outdent'
@@ -284,9 +284,4 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => {
284284
await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest)
285285
}
286286

287-
export const enableEdgeInNextConfig = async (publish: string) => {
288-
const configFile = join(publish, 'required-server-files.json')
289-
const config = await readJSON(configFile)
290-
await writeJSON(configFile, config)
291-
}
292287
/* eslint-enable max-lines */

packages/runtime/src/helpers/files.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import globby from 'globby'
88
import { PrerenderManifest } from 'next/dist/build'
99
import { outdent } from 'outdent'
1010
import pLimit from 'p-limit'
11-
import { join } from 'pathe'
11+
import { join, resolve } from 'pathe'
1212
import slash from 'slash'
1313

1414
import { MINIMUM_REVALIDATE_SECONDS, DIVIDER } from '../constants'
@@ -18,6 +18,7 @@ import { Rewrites, RoutesManifest } from './types'
1818
import { findModuleFromBase } from './utils'
1919

2020
const TEST_ROUTE = /(|\/)\[[^/]+?](\/|\.html|$)/
21+
const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
2122

2223
export const isDynamicRoute = (route) => TEST_ROUTE.test(route)
2324

@@ -333,6 +334,31 @@ const getServerFile = (root: string, includeBase = true) => {
333334
return findModuleFromBase({ candidates, paths: [root] })
334335
}
335336

337+
/**
338+
* Find the source file for a given page route
339+
*/
340+
export const getSourceFileForPage = (page: string, root: string) => {
341+
for (const extension of SOURCE_FILE_EXTENSIONS) {
342+
const file = join(root, `${page}.${extension}`)
343+
if (existsSync(file)) {
344+
return file
345+
}
346+
}
347+
console.log('Could not find source file for page', page)
348+
}
349+
350+
/**
351+
* Reads the node file trace file for a given file, and resolves the dependencies
352+
*/
353+
export const getDependenciesOfFile = async (file: string) => {
354+
const nft = `${file}.nft.json`
355+
if (!existsSync(nft)) {
356+
return []
357+
}
358+
const dependencies = await readJson(nft, 'utf8')
359+
return dependencies.files.map((dep) => resolve(file, dep))
360+
}
361+
336362
const baseServerReplacements: Array<[string, string]> = [
337363
// force manual revalidate during cache fetches
338364
[

packages/runtime/src/helpers/functions.ts

+102-10
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,67 @@
1+
/* eslint-disable max-lines */
12
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build'
23
import bridgeFile from '@vercel/node-bridge'
4+
import chalk from 'chalk'
35
import destr from 'destr'
4-
import { copyFile, ensureDir, writeFile, writeJSON } from 'fs-extra'
6+
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON } from 'fs-extra'
57
import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config'
8+
import { outdent } from 'outdent'
69
import { join, relative, resolve } from 'pathe'
710

811
import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME, DEFAULT_FUNCTIONS_SRC } from '../constants'
12+
import { getApiHandler } from '../templates/getApiHandler'
913
import { getHandler } from '../templates/getHandler'
10-
import { getPageResolver } from '../templates/getPageResolver'
14+
import { getPageResolver, getSinglePageResolver } from '../templates/getPageResolver'
15+
16+
import { ApiConfig, ApiRouteType, extractConfigFromFile } from './analysis'
17+
import { getSourceFileForPage } from './files'
18+
import { getFunctionNameForPage } from './utils'
19+
20+
export interface ApiRouteConfig {
21+
route: string
22+
config: ApiConfig
23+
compiled: string
24+
}
1125

1226
export const generateFunctions = async (
1327
{ FUNCTIONS_SRC = DEFAULT_FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, PUBLISH_DIR }: NetlifyPluginConstants,
1428
appDir: string,
29+
apiRoutes: Array<ApiRouteConfig>,
1530
): Promise<void> => {
16-
const functionsDir = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC
17-
const functionDir = join(process.cwd(), functionsDir, HANDLER_FUNCTION_NAME)
18-
const publishDir = relative(functionDir, resolve(PUBLISH_DIR))
31+
const publish = resolve(PUBLISH_DIR)
32+
const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC)
33+
console.log({ functionsDir })
34+
const functionDir = join(functionsDir, HANDLER_FUNCTION_NAME)
35+
const publishDir = relative(functionDir, publish)
1936

20-
const writeHandler = async (func: string, isODB: boolean) => {
37+
for (const { route, config, compiled } of apiRoutes) {
38+
const apiHandlerSource = await getApiHandler({
39+
page: route,
40+
config,
41+
})
42+
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND)
43+
await ensureDir(join(functionsDir, functionName))
44+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
45+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
46+
await copyFile(
47+
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
48+
join(functionsDir, functionName, 'handlerUtils.js'),
49+
)
50+
const resolverSource = await getSinglePageResolver({
51+
functionsDir,
52+
sourceFile: join(publish, 'server', compiled),
53+
})
54+
await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource)
55+
}
56+
57+
const writeHandler = async (functionName: string, isODB: boolean) => {
2158
const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) })
22-
await ensureDir(join(functionsDir, func))
23-
await writeFile(join(functionsDir, func, `${func}.js`), handlerSource)
24-
await copyFile(bridgeFile, join(functionsDir, func, 'bridge.js'))
59+
await ensureDir(join(functionsDir, functionName))
60+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
61+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
2562
await copyFile(
2663
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
27-
join(functionsDir, func, 'handlerUtils.js'),
64+
join(functionsDir, functionName, 'handlerUtils.js'),
2865
)
2966
}
3067

@@ -124,3 +161,58 @@ export const setupImageFunction = async ({
124161
})
125162
}
126163
}
164+
165+
/**
166+
* Look for API routes, and extract the config from the source file.
167+
*/
168+
export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise<Array<ApiRouteConfig>> => {
169+
const pages = await readJSON(join(publish, 'server', 'pages-manifest.json'))
170+
const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/'))
171+
const pagesDir = join(baseDir, 'pages')
172+
return Promise.all(
173+
apiRoutes.map(async (apiRoute) => {
174+
const filePath = getSourceFileForPage(apiRoute, pagesDir)
175+
return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] }
176+
}),
177+
)
178+
}
179+
180+
interface FunctionsManifest {
181+
functions: Array<{ name: string; schedule?: string }>
182+
}
183+
184+
/**
185+
* Warn the user of the caveats if they're using background or scheduled API routes
186+
*/
187+
188+
export const warnOnApiRoutes = async ({
189+
FUNCTIONS_DIST,
190+
}: Pick<NetlifyPluginConstants, 'FUNCTIONS_DIST'>): Promise<void> => {
191+
const functionsManifestPath = join(FUNCTIONS_DIST, 'manifest.json')
192+
if (!existsSync(functionsManifestPath)) {
193+
return
194+
}
195+
196+
const { functions }: FunctionsManifest = await readJSON(functionsManifestPath)
197+
198+
if (functions.some((func) => func.name.endsWith('-background'))) {
199+
console.warn(
200+
outdent`
201+
${chalk.yellowBright`Using background API routes`}
202+
If your account type does not support background functions, the deploy will fail.
203+
During local development, background API routes will run as regular API routes, but in production they will immediately return an empty "202 Accepted" response.
204+
`,
205+
)
206+
}
207+
208+
if (functions.some((func) => func.schedule)) {
209+
console.warn(
210+
outdent`
211+
${chalk.yellowBright`Using scheduled API routes`}
212+
These are run on a schedule when deployed to production.
213+
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.
214+
`,
215+
)
216+
}
217+
}
218+
/* eslint-enable max-lines */

packages/runtime/src/helpers/redirects.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { join } from 'pathe'
1010
import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants'
1111

1212
import { getMiddleware } from './files'
13+
import { ApiRouteConfig } from './functions'
1314
import { RoutesManifest } from './types'
1415
import {
1516
getApiRewrites,
@@ -228,10 +229,12 @@ export const generateRedirects = async ({
228229
netlifyConfig,
229230
nextConfig: { i18n, basePath, trailingSlash, appDir },
230231
buildId,
232+
apiRoutes,
231233
}: {
232234
netlifyConfig: NetlifyConfig
233235
nextConfig: Pick<NextConfig, 'i18n' | 'basePath' | 'trailingSlash' | 'appDir'>
234236
buildId: string
237+
apiRoutes: Array<ApiRouteConfig>
235238
}) => {
236239
const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest =
237240
await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json'))
@@ -249,7 +252,7 @@ export const generateRedirects = async ({
249252
// This is only used in prod, so dev uses `next dev` directly
250253
netlifyConfig.redirects.push(
251254
// API routes always need to be served from the regular function
252-
...getApiRewrites(basePath),
255+
...getApiRewrites(basePath, apiRoutes),
253256
// Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped
254257
...(await getPreviewRewrites({ basePath, appDir })),
255258
)

0 commit comments

Comments
 (0)