Skip to content

feat: split api routes into separate functions #1495

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
25f3e2f
feat: add static analysis helper
ascorbic Jul 30, 2022
fbfd0cc
feat: split api routes into separate functions
ascorbic Jul 31, 2022
57b31b2
chore: fix syntax of api handlers for better static analysis
ascorbic Jul 31, 2022
dc8c3de
fix: broken test
ascorbic Jul 31, 2022
47f0a95
Merge branch 'main' into mk/background-functions
ascorbic Aug 7, 2022
9d1dfdd
chore: change config shape
ascorbic Aug 8, 2022
b3cc08e
Merge branch 'mk/background-functions' of github.com:netlify/netlify-…
ascorbic Aug 8, 2022
1786f84
chore: fix test
ascorbic Aug 8, 2022
8f312f1
chore: windows paths :angry:
ascorbic Aug 8, 2022
5f582b2
chore: add e2e tests for extended API routes
ascorbic Aug 8, 2022
56abd3a
Merge branch 'main' into mk/background-functions
ascorbic Aug 19, 2022
00a80a7
chore: fix cypress 404 test
ascorbic Aug 19, 2022
c266478
chore: fix import
ascorbic Aug 19, 2022
59e5607
Merge branch 'main' into mk/background-functions
ascorbic Aug 21, 2022
6b5c991
Merge branch 'main' into mk/background-functions
ascorbic Sep 5, 2022
c1fc1be
chore: lint
ascorbic Sep 5, 2022
bc662f8
Merge branch 'main' into mk/background-functions
ascorbic Sep 23, 2022
bb65d50
chore: remove unused function
ascorbic Sep 23, 2022
daeb493
Merge branch 'main' into mk/background-functions
ascorbic Oct 5, 2022
6aad6b1
chore: use enum for api route type
ascorbic Oct 6, 2022
df28822
Merge branch 'main' into mk/background-functions
ascorbic Oct 6, 2022
7cbe3ef
chore: snapidoo
ascorbic Oct 6, 2022
56d5a06
chore: lockfile
ascorbic Oct 6, 2022
0d3152f
chore: debug preview test
ascorbic Oct 6, 2022
f83d562
chore: use const enum
ascorbic Oct 6, 2022
8305373
Merge branch 'main' into mk/background-functions
ascorbic Oct 6, 2022
5354005
Merge branch 'main' into mk/background-functions
ascorbic Oct 7, 2022
898a8a7
feat: add warning logs for advanced api routes
ascorbic Oct 7, 2022
a190160
Merge branch 'main' into mk/background-functions
ascorbic Oct 17, 2022
781be0c
chore: support jsx
ascorbic Oct 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cypress/integration/default/api.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
9 changes: 9 additions & 0 deletions demos/default/pages/api/hello-background.ts
Original file line number Diff line number Diff line change
@@ -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',
}
10 changes: 10 additions & 0 deletions demos/default/pages/api/hello-scheduled.js
Original file line number Diff line number Diff line change
@@ -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',
}
103 changes: 103 additions & 0 deletions packages/runtime/src/helpers/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import fs 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'

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<ApiConfig> => {
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)}`)
}
2 changes: 1 addition & 1 deletion packages/runtime/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||= []
Expand Down
7 changes: 1 addition & 6 deletions packages/runtime/src/helpers/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -269,9 +269,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 */
26 changes: 25 additions & 1 deletion packages/runtime/src/helpers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -333,6 +333,30 @@ 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}`)
if (existsSync(file)) {
return file
}
}
}

/**
* 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
[
Expand Down
69 changes: 59 additions & 10 deletions packages/runtime/src/helpers/functions.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,64 @@
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build'
import bridgeFile from '@vercel/node-bridge'
import destr from 'destr'
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<ApiRouteConfig>,
): Promise<void> => {
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)

for (const { route, config, compiled } of apiRoutes) {
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'))
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 (func: string, isODB: boolean) => {
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'),
)
}

Expand Down Expand Up @@ -124,3 +158,18 @@ 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<Array<ApiRouteConfig>> => {
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] }
}),
)
}
5 changes: 4 additions & 1 deletion packages/runtime/src/helpers/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -228,10 +229,12 @@ export const generateRedirects = async ({
netlifyConfig,
nextConfig: { i18n, basePath, trailingSlash, appDir },
buildId,
apiRoutes,
}: {
netlifyConfig: NetlifyConfig
nextConfig: Pick<NextConfig, 'i18n' | 'basePath' | 'trailingSlash' | 'appDir'>
buildId: string
apiRoutes: Array<ApiRouteConfig>
}) => {
const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest =
await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json'))
Expand All @@ -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 })),
)
Expand Down
Loading