Skip to content

Commit 2b22248

Browse files
authored
Merge 5f582b2 into ea35eae
2 parents ea35eae + 5f582b2 commit 2b22248

28 files changed

+719
-37
lines changed
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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('returns 404 for scheduled route', () => {
8+
cy.request('/api/hello-scheduled').then((response) => {
9+
expect(response.status).to.equal(404)
10+
})
11+
})
12+
})
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+
}

plugin/src/helpers/analysis.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import fs 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+
export interface ApiStandardConfig {
8+
type?: never
9+
runtime?: 'nodejs' | 'experimental-edge'
10+
schedule?: never
11+
}
12+
13+
export interface ApiScheduledConfig {
14+
type: 'experimental-scheduled'
15+
runtime?: 'nodejs'
16+
schedule: string
17+
}
18+
19+
export interface ApiBackgroundConfig {
20+
type: 'experimental-background'
21+
runtime?: 'nodejs'
22+
schedule?: never
23+
}
24+
25+
export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig
26+
27+
export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => {
28+
if (config.type === 'experimental-scheduled') {
29+
if (!config.schedule) {
30+
console.error(
31+
`Invalid config value in ${relative(
32+
process.cwd(),
33+
apiFilePath,
34+
)}: schedule is required when type is "experimental-scheduled"`,
35+
)
36+
return false
37+
}
38+
if ((config as ApiConfig).runtime === 'experimental-edge') {
39+
console.error(
40+
`Invalid config value in ${relative(
41+
process.cwd(),
42+
apiFilePath,
43+
)}: edge runtime is not supported for scheduled functions`,
44+
)
45+
return false
46+
}
47+
return true
48+
}
49+
50+
if (!config.type || config.type === 'experimental-background') {
51+
if (config.schedule) {
52+
console.error(
53+
`Invalid config value in ${relative(
54+
process.cwd(),
55+
apiFilePath,
56+
)}: schedule is not allowed unless type is "experimental-scheduled"`,
57+
)
58+
return false
59+
}
60+
if (config.type && (config as ApiConfig).runtime === 'experimental-edge') {
61+
console.error(
62+
`Invalid config value in ${relative(
63+
process.cwd(),
64+
apiFilePath,
65+
)}: edge runtime is not supported for background functions`,
66+
)
67+
return false
68+
}
69+
return true
70+
}
71+
console.error(
72+
`Invalid config value in ${relative(process.cwd(), apiFilePath)}: type ${
73+
(config as ApiConfig).type
74+
} is not supported`,
75+
)
76+
return false
77+
}
78+
79+
/**
80+
* Uses Next's swc static analysis to extract the config values from a file.
81+
*/
82+
export const extractConfigFromFile = async (apiFilePath: string): Promise<ApiConfig> => {
83+
const fileContent = await fs.promises.readFile(apiFilePath, 'utf8')
84+
// No need to parse if there's no "config"
85+
if (!fileContent.includes('config')) {
86+
return {}
87+
}
88+
const ast = await parseModule(apiFilePath, fileContent)
89+
90+
let config: ApiConfig
91+
try {
92+
config = extractExportedConstValue(ast, 'config')
93+
} catch (error) {
94+
if (error instanceof UnsupportedValueError) {
95+
console.warn(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
96+
}
97+
return {}
98+
}
99+
if (validateConfigValue(config, apiFilePath)) {
100+
return config
101+
}
102+
throw new Error(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
103+
}

plugin/src/helpers/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore
8989
netlifyConfig.functions._ipx.node_bundler = 'nft'
9090

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

plugin/src/helpers/files.ts

+25-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'
@@ -325,6 +325,30 @@ const getServerFile = (root: string, includeBase = true) => {
325325
return findModuleFromBase({ candidates, paths: [root] })
326326
}
327327

328+
/**
329+
* Find the source file for a given page route
330+
*/
331+
export const getSourceFileForPage = (page: string, root: string) => {
332+
for (const extension of ['ts', 'js']) {
333+
const file = join(root, `${page}.${extension}`)
334+
if (existsSync(file)) {
335+
return file
336+
}
337+
}
338+
}
339+
340+
/**
341+
* Reads the node file trace file for a given file, and resolves the dependencies
342+
*/
343+
export const getDependenciesOfFile = async (file: string) => {
344+
const nft = `${file}.nft.json`
345+
if (!existsSync(nft)) {
346+
return []
347+
}
348+
const dependencies = await readJson(nft, 'utf8')
349+
return dependencies.files.map((dep) => resolve(file, dep))
350+
}
351+
328352
const baseServerReplacements: Array<[string, string]> = [
329353
[`let ssgCacheKey = `, `let ssgCacheKey = process.env._BYPASS_SSG || `],
330354
]

plugin/src/helpers/functions.ts

+59-10
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,63 @@
11
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build'
22
import bridgeFile from '@vercel/node-bridge'
3-
import { copyFile, ensureDir, writeFile, writeJSON } from 'fs-extra'
3+
import { copyFile, ensureDir, readJSON, writeFile, writeJSON } from 'fs-extra'
44
import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config'
55
import { join, relative, resolve } from 'pathe'
66

77
import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME, DEFAULT_FUNCTIONS_SRC } from '../constants'
8+
import { getApiHandler } from '../templates/getApiHandler'
89
import { getHandler } from '../templates/getHandler'
9-
import { getPageResolver } from '../templates/getPageResolver'
10+
import { getPageResolver, getSinglePageResolver } from '../templates/getPageResolver'
11+
12+
import { ApiConfig, extractConfigFromFile } from './analysis'
13+
import { getSourceFileForPage } from './files'
14+
import { getFunctionNameForPage } from './utils'
15+
16+
export interface ApiRouteConfig {
17+
route: string
18+
config: ApiConfig
19+
compiled: string
20+
}
1021

1122
export const generateFunctions = async (
1223
{ FUNCTIONS_SRC = DEFAULT_FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, PUBLISH_DIR }: NetlifyPluginConstants,
1324
appDir: string,
25+
apiRoutes: Array<ApiRouteConfig>,
1426
): Promise<void> => {
15-
const functionsDir = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC
16-
const functionDir = join(process.cwd(), functionsDir, HANDLER_FUNCTION_NAME)
17-
const publishDir = relative(functionDir, resolve(PUBLISH_DIR))
27+
const publish = resolve(PUBLISH_DIR)
28+
const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC)
29+
console.log({ functionsDir })
30+
const functionDir = join(functionsDir, HANDLER_FUNCTION_NAME)
31+
const publishDir = relative(functionDir, publish)
32+
33+
for (const { route, config, compiled } of apiRoutes) {
34+
const apiHandlerSource = await getApiHandler({
35+
page: route,
36+
config,
37+
})
38+
const functionName = getFunctionNameForPage(route, config.type === 'experimental-background')
39+
await ensureDir(join(functionsDir, functionName))
40+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
41+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
42+
await copyFile(
43+
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
44+
join(functionsDir, functionName, 'handlerUtils.js'),
45+
)
46+
const resolverSource = await getSinglePageResolver({
47+
functionsDir,
48+
sourceFile: join(publish, 'server', compiled),
49+
})
50+
await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource)
51+
}
1852

19-
const writeHandler = async (func: string, isODB: boolean) => {
53+
const writeHandler = async (functionName: string, isODB: boolean) => {
2054
const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) })
21-
await ensureDir(join(functionsDir, func))
22-
await writeFile(join(functionsDir, func, `${func}.js`), handlerSource)
23-
await copyFile(bridgeFile, join(functionsDir, func, 'bridge.js'))
55+
await ensureDir(join(functionsDir, functionName))
56+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
57+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
2458
await copyFile(
2559
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
26-
join(functionsDir, func, 'handlerUtils.js'),
60+
join(functionsDir, functionName, 'handlerUtils.js'),
2761
)
2862
}
2963

@@ -105,3 +139,18 @@ export const setupImageFunction = async ({
105139
})
106140
}
107141
}
142+
143+
/**
144+
* Look for API routes, and extract the config from the source file.
145+
*/
146+
export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise<Array<ApiRouteConfig>> => {
147+
const pages = await readJSON(join(publish, 'server', 'pages-manifest.json'))
148+
const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/'))
149+
const pagesDir = join(baseDir, 'pages')
150+
return Promise.all(
151+
apiRoutes.map(async (apiRoute) => {
152+
const filePath = getSourceFileForPage(apiRoute, pagesDir)
153+
return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] }
154+
}),
155+
)
156+
}

plugin/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,
@@ -219,10 +220,12 @@ export const generateRedirects = async ({
219220
netlifyConfig,
220221
nextConfig: { i18n, basePath, trailingSlash, appDir },
221222
buildId,
223+
apiRoutes,
222224
}: {
223225
netlifyConfig: NetlifyConfig
224226
nextConfig: Pick<NextConfig, 'i18n' | 'basePath' | 'trailingSlash' | 'appDir'>
225227
buildId: string
228+
apiRoutes: Array<ApiRouteConfig>
226229
}) => {
227230
const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest =
228231
await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json'))
@@ -247,7 +250,7 @@ export const generateRedirects = async ({
247250
// This is only used in prod, so dev uses `next dev` directly
248251
netlifyConfig.redirects.push(
249252
// API routes always need to be served from the regular function
250-
...getApiRewrites(basePath),
253+
...getApiRewrites(basePath, apiRoutes),
251254
// Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped
252255
...(await getPreviewRewrites({ basePath, appDir })),
253256
)

plugin/src/helpers/utils.ts

+49-12
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1+
/* eslint-disable max-lines */
12
import type { NetlifyConfig } from '@netlify/build'
23
import globby from 'globby'
34
import { join } from 'pathe'
45

56
import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants'
67

8+
import type { ApiRouteConfig } from './functions'
79
import { I18n } from './types'
810

11+
const RESERVED_FILENAME = /[^\w_-]/g
12+
13+
/**
14+
* Given a Next route, generates a valid Netlify function name.
15+
* If "background" is true then the function name will have `-background`
16+
* appended to it, meaning that it is executed as a background function.
17+
*/
18+
export const getFunctionNameForPage = (page: string, background = false) =>
19+
`${page
20+
.replace(CATCH_ALL_REGEX, '_$1-SPLAT')
21+
.replace(OPTIONAL_CATCH_ALL_REGEX, '-SPLAT')
22+
.replace(DYNAMIC_PARAMETER_REGEX, '_$1-PARAM')
23+
.replace(RESERVED_FILENAME, '_')}-${background ? 'background' : 'handler'}`
24+
925
export const toNetlifyRoute = (nextRoute: string): Array<string> => {
1026
const netlifyRoutes = [nextRoute]
1127

@@ -117,18 +133,38 @@ export const redirectsForNextRouteWithData = ({
117133
force,
118134
}))
119135

120-
export const getApiRewrites = (basePath) => [
121-
{
122-
from: `${basePath}/api`,
123-
to: HANDLER_FUNCTION_PATH,
124-
status: 200,
125-
},
126-
{
127-
from: `${basePath}/api/*`,
128-
to: HANDLER_FUNCTION_PATH,
129-
status: 200,
130-
},
131-
]
136+
export const getApiRewrites = (basePath: string, apiRoutes: Array<ApiRouteConfig>) => {
137+
const apiRewrites = apiRoutes.map((apiRoute) => {
138+
const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`)
139+
140+
// Scheduled functions can't be invoked directly, so we 404 them.
141+
if (apiRoute.config.type === 'experimental-scheduled') {
142+
return { from, to: '/404.html', status: 404 }
143+
}
144+
return {
145+
from,
146+
to: `/.netlify/functions/${getFunctionNameForPage(
147+
apiRoute.route,
148+
apiRoute.config.type === 'experimental-background',
149+
)}`,
150+
status: 200,
151+
}
152+
})
153+
154+
return [
155+
...apiRewrites,
156+
{
157+
from: `${basePath}/api`,
158+
to: HANDLER_FUNCTION_PATH,
159+
status: 200,
160+
},
161+
{
162+
from: `${basePath}/api/*`,
163+
to: HANDLER_FUNCTION_PATH,
164+
status: 200,
165+
},
166+
]
167+
}
132168

133169
export const getPreviewRewrites = async ({ basePath, appDir }) => {
134170
const publicFiles = await globby('**/*', { cwd: join(appDir, 'public') })
@@ -185,3 +221,4 @@ export const isNextAuthInstalled = (): boolean => {
185221
return false
186222
}
187223
}
224+
/* eslint-enable max-lines */

0 commit comments

Comments
 (0)