Skip to content

Commit fbfd0cc

Browse files
committed
feat: split api routes into separate functions
1 parent 25f3e2f commit fbfd0cc

File tree

12 files changed

+319
-37
lines changed

12 files changed

+319
-37
lines changed

plugin/src/helpers/analysis.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { relative } from 'path'
44
import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value'
55
import { parseModule } from 'next/dist/build/analysis/parse-module'
66

7-
export const extractConfigFromFile = async (apiFilePath: string): Promise<Record<string, unknown>> => {
7+
export interface ApiConfig {
8+
runtime?: 'node' | 'experimental-edge'
9+
background?: boolean
10+
schedule?: string
11+
}
12+
13+
export const extractConfigFromFile = async (apiFilePath: string): Promise<ApiConfig> => {
814
const fileContent = await fs.promises.readFile(apiFilePath, 'utf8')
915
if (!fileContent.includes('config')) {
1016
return {}

plugin/src/helpers/config.ts

Lines changed: 1 addition & 1 deletion
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

Lines changed: 19 additions & 1 deletion
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,24 @@ const getServerFile = (root: string, includeBase = true) => {
325325
return findModuleFromBase({ candidates, paths: [root] })
326326
}
327327

328+
export const getSourceFileForPage = (page: string, root: string) => {
329+
for (const extension of ['ts', 'js']) {
330+
const file = join(root, `${page}.${extension}`)
331+
if (existsSync(file)) {
332+
return file
333+
}
334+
}
335+
}
336+
337+
export const getDependenciesOfFile = async (file: string) => {
338+
const nft = `${file}.nft.json`
339+
if (!existsSync(nft)) {
340+
return []
341+
}
342+
const dependencies = await readJson(nft, 'utf8')
343+
return dependencies.files.map((dep) => resolve(file, dep))
344+
}
345+
328346
const baseServerReplacements: Array<[string, string]> = [
329347
[`let ssgCacheKey = `, `let ssgCacheKey = process.env._BYPASS_SSG || `],
330348
]

plugin/src/helpers/functions.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,60 @@
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)
1832

19-
const writeHandler = async (func: string, isODB: boolean) => {
33+
for (const { route, config, compiled } of apiRoutes) {
34+
const apiHandlerSource = await getApiHandler({ page: route, schedule: config.schedule })
35+
const functionName = getFunctionNameForPage(route, config.background)
36+
await ensureDir(join(functionsDir, functionName))
37+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
38+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
39+
await copyFile(
40+
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
41+
join(functionsDir, functionName, 'handlerUtils.js'),
42+
)
43+
const resolverSource = await getSinglePageResolver({
44+
functionsDir,
45+
sourceFile: join(publish, 'server', compiled),
46+
})
47+
await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource)
48+
}
49+
50+
const writeHandler = async (functionName: string, isODB: boolean) => {
2051
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'))
52+
await ensureDir(join(functionsDir, functionName))
53+
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
54+
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
2455
await copyFile(
2556
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
26-
join(functionsDir, func, 'handlerUtils.js'),
57+
join(functionsDir, functionName, 'handlerUtils.js'),
2758
)
2859
}
2960

@@ -105,3 +136,15 @@ export const setupImageFunction = async ({
105136
})
106137
}
107138
}
139+
140+
export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise<Array<ApiRouteConfig>> => {
141+
const pages = await readJSON(join(publish, 'server', 'pages-manifest.json'))
142+
const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/'))
143+
const pagesDir = join(baseDir, 'pages')
144+
return Promise.all(
145+
apiRoutes.map(async (apiRoute) => {
146+
const filePath = getSourceFileForPage(apiRoute, pagesDir)
147+
return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] }
148+
}),
149+
)
150+
}

plugin/src/helpers/redirects.ts

Lines changed: 4 additions & 1 deletion
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

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
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+
// // Replace catch-all, e.g., [...slug]
15+
// .replace(CATCH_ALL_REGEX, '/:$1/*')
16+
// // Replace optional catch-all, e.g., [[...slug]]
17+
// .replace(OPTIONAL_CATCH_ALL_REGEX, '/*')
18+
// // Replace dynamic parameters, e.g., [id]
19+
// .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'),
20+
//
21+
22+
export const getFunctionNameForPage = (page: string, background = false) =>
23+
`${page
24+
.replace(CATCH_ALL_REGEX, '_$1-SPLAT')
25+
.replace(OPTIONAL_CATCH_ALL_REGEX, '-SPLAT')
26+
.replace(DYNAMIC_PARAMETER_REGEX, '_$1-PARAM')
27+
.replace(RESERVED_FILENAME, '_')}-${background ? 'background' : 'handler'}`
28+
929
export const toNetlifyRoute = (nextRoute: string): Array<string> => {
1030
const netlifyRoutes = [nextRoute]
1131

@@ -117,18 +137,35 @@ export const redirectsForNextRouteWithData = ({
117137
force,
118138
}))
119139

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-
]
140+
export const getApiRewrites = (basePath: string, apiRoutes: Array<ApiRouteConfig>) => {
141+
const apiRewrites = apiRoutes.map((apiRoute) => {
142+
const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`)
143+
144+
// Scheduled functions can't be invoked directly
145+
if (apiRoute.config.schedule) {
146+
return { from, to: '/404.html', status: 404 }
147+
}
148+
return {
149+
from,
150+
to: `/.netlify/functions/${getFunctionNameForPage(apiRoute.route, apiRoute.config.background)}`,
151+
status: 200,
152+
}
153+
})
154+
155+
return [
156+
...apiRewrites,
157+
{
158+
from: `${basePath}/api`,
159+
to: HANDLER_FUNCTION_PATH,
160+
status: 200,
161+
},
162+
{
163+
from: `${basePath}/api/*`,
164+
to: HANDLER_FUNCTION_PATH,
165+
status: 200,
166+
},
167+
]
168+
}
132169

133170
export const getPreviewRewrites = async ({ basePath, appDir }) => {
134171
const publicFiles = await globby('**/*', { cwd: join(appDir, 'public') })
@@ -185,3 +222,4 @@ export const isNextAuthInstalled = (): boolean => {
185222
return false
186223
}
187224
}
225+
/* eslint-enable max-lines */

plugin/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from './helpers/config'
1818
import { updateConfig, writeEdgeFunctions, loadMiddlewareManifest } from './helpers/edge'
1919
import { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } from './helpers/files'
20-
import { generateFunctions, setupImageFunction, generatePagesResolver } from './helpers/functions'
20+
import { generateFunctions, setupImageFunction, generatePagesResolver, getApiRouteConfigs } from './helpers/functions'
2121
import { generateRedirects, generateStaticRedirects } from './helpers/redirects'
2222
import { shouldSkip, isNextAuthInstalled } from './helpers/utils'
2323
import {
@@ -106,13 +106,14 @@ const plugin: NetlifyPlugin = {
106106
const buildId = readFileSync(join(publish, 'BUILD_ID'), 'utf8').trim()
107107

108108
await configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) })
109+
const apiRoutes = await getApiRouteConfigs(publish, appDir)
109110

110-
await generateFunctions(constants, appDir)
111+
await generateFunctions(constants, appDir, apiRoutes)
111112
await generatePagesResolver({ target, constants })
112113

113114
await movePublicFiles({ appDir, outdir, publish })
114115

115-
await patchNextFiles(basePath)
116+
await patchNextFiles(appDir)
116117

117118
if (!process.env.SERVE_STATIC_FILES_FROM_ORIGIN) {
118119
await moveStaticPages({ target, netlifyConfig, i18n, basePath })
@@ -135,6 +136,7 @@ const plugin: NetlifyPlugin = {
135136
netlifyConfig,
136137
nextConfig: { basePath, i18n, trailingSlash, appDir },
137138
buildId,
139+
apiRoutes,
138140
})
139141

140142
// We call this even if we don't have edge functions enabled because we still use it for images

0 commit comments

Comments
 (0)