Skip to content

Commit 275f488

Browse files
authoredApr 16, 2024
feat: fail the build when advanced api routes are used (#403)
* test: add advanced routes fixture * feat: fail the build when advanced api routes are used * chore: update failBuild message with mention of migration and link to example * test: add integration test for build failing error message when advanced api routes are used * chore: drop console.logs inherited from v4 advanced api routes handling code * fix: lint * chore: use shortened link for migration example * test: update assertion * test: unrelated smoke test update
1 parent 9445b79 commit 275f488

11 files changed

+338
-1
lines changed
 

‎src/build/advanced-api-routes.ts

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { existsSync } from 'node:fs'
2+
import { readFile } from 'node:fs/promises'
3+
import { join } from 'node:path'
4+
5+
import type { PluginContext } from './plugin-context.js'
6+
7+
interface FunctionsConfigManifest {
8+
version: number
9+
functions: Record<string, Record<string, string | number>>
10+
}
11+
12+
// eslint-disable-next-line no-shadow
13+
export const enum ApiRouteType {
14+
SCHEDULED = 'experimental-scheduled',
15+
BACKGROUND = 'experimental-background',
16+
}
17+
18+
interface ApiStandardConfig {
19+
type?: never
20+
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
21+
schedule?: never
22+
}
23+
24+
interface ApiScheduledConfig {
25+
type: ApiRouteType.SCHEDULED
26+
runtime?: 'nodejs'
27+
schedule: string
28+
}
29+
30+
interface ApiBackgroundConfig {
31+
type: ApiRouteType.BACKGROUND
32+
runtime?: 'nodejs'
33+
schedule?: never
34+
}
35+
36+
type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig
37+
38+
export async function getAPIRoutesConfigs(ctx: PluginContext) {
39+
const functionsConfigManifestPath = join(
40+
ctx.publishDir,
41+
'server',
42+
'functions-config-manifest.json',
43+
)
44+
if (!existsSync(functionsConfigManifestPath)) {
45+
// before https://github.com/vercel/next.js/pull/60163 this file might not have been produced if there were no API routes at all
46+
return []
47+
}
48+
49+
const functionsConfigManifest = JSON.parse(
50+
await readFile(functionsConfigManifestPath, 'utf-8'),
51+
) as FunctionsConfigManifest
52+
53+
const appDir = ctx.resolveFromSiteDir('.')
54+
const pagesDir = join(appDir, 'pages')
55+
const srcPagesDir = join(appDir, 'src', 'pages')
56+
const { pageExtensions } = ctx.requiredServerFiles.config
57+
58+
return Promise.all(
59+
Object.keys(functionsConfigManifest.functions).map(async (apiRoute) => {
60+
const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions)
61+
62+
const sharedFields = {
63+
apiRoute,
64+
filePath,
65+
config: {} as ApiConfig,
66+
}
67+
68+
if (filePath) {
69+
const config = await extractConfigFromFile(filePath, appDir)
70+
return {
71+
...sharedFields,
72+
config,
73+
}
74+
}
75+
76+
return sharedFields
77+
}),
78+
)
79+
}
80+
81+
// Next.js already defines a default `pageExtensions` array in its `required-server-files.json` file
82+
// In case it gets `undefined`, this is a fallback
83+
const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
84+
85+
/**
86+
* Find the source file for a given page route
87+
*/
88+
const getSourceFileForPage = (
89+
page: string,
90+
roots: string[],
91+
pageExtensions = SOURCE_FILE_EXTENSIONS,
92+
) => {
93+
for (const root of roots) {
94+
for (const extension of pageExtensions) {
95+
const file = join(root, `${page}.${extension}`)
96+
if (existsSync(file)) {
97+
return file
98+
}
99+
100+
const fileAtFolderIndex = join(root, page, `index.${extension}`)
101+
if (existsSync(fileAtFolderIndex)) {
102+
return fileAtFolderIndex
103+
}
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Given an array of base paths and candidate modules, return the first one that exists
110+
*/
111+
const findModuleFromBase = ({
112+
paths,
113+
candidates,
114+
}: {
115+
paths: string[]
116+
candidates: string[]
117+
}): string | null => {
118+
for (const candidate of candidates) {
119+
try {
120+
const modulePath = require.resolve(candidate, { paths })
121+
if (modulePath) {
122+
return modulePath
123+
}
124+
} catch {
125+
// Ignore the error
126+
}
127+
}
128+
// if we couldn't find a module from paths, let's try to resolve from here
129+
for (const candidate of candidates) {
130+
try {
131+
const modulePath = require.resolve(candidate)
132+
if (modulePath) {
133+
return modulePath
134+
}
135+
} catch {
136+
// Ignore the error
137+
}
138+
}
139+
return null
140+
}
141+
142+
let extractConstValue: typeof import('next/dist/build/analysis/extract-const-value.js')
143+
let parseModule: typeof import('next/dist/build/analysis/parse-module.js').parseModule
144+
145+
const extractConfigFromFile = async (apiFilePath: string, appDir: string): Promise<ApiConfig> => {
146+
if (!apiFilePath || !existsSync(apiFilePath)) {
147+
return {}
148+
}
149+
150+
const extractConstValueModulePath = findModuleFromBase({
151+
paths: [appDir],
152+
candidates: ['next/dist/build/analysis/extract-const-value'],
153+
})
154+
155+
const parseModulePath = findModuleFromBase({
156+
paths: [appDir],
157+
candidates: ['next/dist/build/analysis/parse-module'],
158+
})
159+
160+
if (!extractConstValueModulePath || !parseModulePath) {
161+
// Old Next.js version
162+
return {}
163+
}
164+
165+
if (!extractConstValue && extractConstValueModulePath) {
166+
// eslint-disable-next-line import/no-dynamic-require, n/global-require
167+
extractConstValue = require(extractConstValueModulePath)
168+
}
169+
if (!parseModule && parseModulePath) {
170+
// eslint-disable-next-line prefer-destructuring, @typescript-eslint/no-var-requires, import/no-dynamic-require, n/global-require
171+
parseModule = require(parseModulePath).parseModule
172+
}
173+
174+
const { extractExportedConstValue } = extractConstValue
175+
176+
const fileContent = await readFile(apiFilePath, 'utf8')
177+
// No need to parse if there's no "config"
178+
if (!fileContent.includes('config')) {
179+
return {}
180+
}
181+
const ast = await parseModule(apiFilePath, fileContent)
182+
183+
try {
184+
return extractExportedConstValue(ast, 'config') as ApiConfig
185+
} catch {
186+
return {}
187+
}
188+
}

‎src/build/verification.ts

+18
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
22

33
import { satisfies } from 'semver'
44

5+
import { ApiRouteType, getAPIRoutesConfigs } from './advanced-api-routes.js'
56
import type { PluginContext } from './plugin-context.js'
67

78
const SUPPORTED_NEXT_VERSIONS = '>=13.5.0'
@@ -66,3 +67,20 @@ export function verifyBuildConfig(ctx: PluginContext) {
6667
)
6768
}
6869
}
70+
71+
export async function verifyNoAdvancedAPIRoutes(ctx: PluginContext) {
72+
const apiRoutesConfigs = await getAPIRoutesConfigs(ctx)
73+
74+
const unsupportedAPIRoutes = apiRoutesConfigs.filter((apiRouteConfig) => {
75+
return (
76+
apiRouteConfig.config.type === ApiRouteType.BACKGROUND ||
77+
apiRouteConfig.config.type === ApiRouteType.SCHEDULED
78+
)
79+
})
80+
81+
if (unsupportedAPIRoutes.length !== 0) {
82+
ctx.failBuild(
83+
`@netlify/plugin-next@5 does not support advanced API routes. The following API routes should be migrated to Netlify background or scheduled functions:\n${unsupportedAPIRoutes.map((apiRouteConfig) => ` - ${apiRouteConfig.apiRoute} (type: "${apiRouteConfig.config.type}")`).join('\n')}\n\nRefer to https://ntl.fyi/next-scheduled-bg-function-migration as migration example.`,
84+
)
85+
}
86+
}

‎src/index.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { createEdgeHandlers } from './build/functions/edge.js'
1515
import { createServerHandler } from './build/functions/server.js'
1616
import { setImageConfig } from './build/image-cdn.js'
1717
import { PluginContext } from './build/plugin-context.js'
18-
import { verifyBuildConfig, verifyPublishDir } from './build/verification.js'
18+
import {
19+
verifyBuildConfig,
20+
verifyNoAdvancedAPIRoutes,
21+
verifyPublishDir,
22+
} from './build/verification.js'
1923

2024
const tracer = wrapTracer(trace.getTracer('Next.js runtime'))
2125

@@ -48,6 +52,8 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
4852
return copyStaticExport(ctx)
4953
}
5054

55+
await verifyNoAdvancedAPIRoutes(ctx)
56+
5157
await Promise.all([
5258
copyStaticAssets(ctx),
5359
copyStaticContent(ctx),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/basic-features/typescript for more information.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
output: 'standalone',
4+
eslint: {
5+
ignoreDuringBuilds: true,
6+
},
7+
generateBuildId: () => 'build-id',
8+
}
9+
10+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "advanced-api-routes",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"postinstall": "next build",
7+
"dev": "next dev",
8+
"build": "next build"
9+
},
10+
"dependencies": {
11+
"@netlify/functions": "^2.5.1",
12+
"next": "latest",
13+
"react": "18.2.0",
14+
"react-dom": "18.2.0"
15+
},
16+
"devDependencies": {
17+
"@types/react": "18.2.75"
18+
}
19+
}
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+
}
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"compilerOptions": {
3+
"lib": [
4+
"dom",
5+
"dom.iterable",
6+
"esnext"
7+
],
8+
"allowJs": true,
9+
"skipLibCheck": true,
10+
"strict": false,
11+
"noEmit": true,
12+
"incremental": true,
13+
"esModuleInterop": true,
14+
"module": "esnext",
15+
"moduleResolution": "node",
16+
"resolveJsonModule": true,
17+
"isolatedModules": true,
18+
"jsx": "preserve"
19+
},
20+
"include": [
21+
"next-env.d.ts",
22+
"**/*.ts",
23+
"**/*.tsx"
24+
],
25+
"exclude": [
26+
"node_modules"
27+
]
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getLogger } from 'lambda-local'
2+
import { v4 } from 'uuid'
3+
import { beforeEach, vi, it, expect } from 'vitest'
4+
import { createFixture, runPlugin, type FixtureTestContext } from '../utils/fixture.js'
5+
import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
6+
7+
getLogger().level = 'alert'
8+
9+
beforeEach<FixtureTestContext>(async (ctx) => {
10+
// set for each test a new deployID and siteID
11+
ctx.deployID = generateRandomObjectID()
12+
ctx.siteID = v4()
13+
vi.stubEnv('SITE_ID', ctx.siteID)
14+
vi.stubEnv('DEPLOY_ID', ctx.deployID)
15+
vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'fake-token')
16+
// hide debug logs in tests
17+
// vi.spyOn(console, 'debug').mockImplementation(() => {})
18+
19+
await startMockBlobStore(ctx)
20+
})
21+
22+
it<FixtureTestContext>('test', async (ctx) => {
23+
await createFixture('advanced-api-routes', ctx)
24+
25+
const runPluginPromise = runPlugin(ctx)
26+
27+
await expect(runPluginPromise).rejects.toThrow(
28+
'@netlify/plugin-next@5 does not support advanced API routes. The following API routes should be migrated to Netlify background or scheduled functions:',
29+
)
30+
31+
// list API routes to migrate
32+
await expect(runPluginPromise).rejects.toThrow(
33+
'/api/hello-scheduled (type: "experimental-scheduled")',
34+
)
35+
await expect(runPluginPromise).rejects.toThrow(
36+
'/api/hello-background (type: "experimental-background")',
37+
)
38+
39+
// links to migration example
40+
await expect(runPluginPromise).rejects.toThrow(
41+
'Refer to https://ntl.fyi/next-scheduled-bg-function-migration as migration example.',
42+
)
43+
})

‎tests/utils/create-e2e-fixture.ts

+1
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ export const fixtureFactories = {
318318
buildCommand: 'yarn build',
319319
publishDirectory: 'apps/site/.next',
320320
smoke: true,
321+
runtimeInstallationPath: '',
321322
}),
322323
npmMonorepoEmptyBaseNoPackagePath: () =>
323324
createE2EFixture('npm-monorepo-empty-base', {

0 commit comments

Comments
 (0)
Please sign in to comment.